Skip to content

Feature/physics#47

Open
ViPErCZ wants to merge 42 commits into
masterfrom
feature/physics
Open

Feature/physics#47
ViPErCZ wants to merge 42 commits into
masterfrom
feature/physics

Conversation

@ViPErCZ
Copy link
Copy Markdown
Owner

@ViPErCZ ViPErCZ commented May 20, 2026

No description provided.

ViPErCZ and others added 30 commits May 13, 2026 10:53
Phase B1 of shader-system refactor towards Limitless-style composition.
Additive only - no behavior change for existing shaders.

- ShaderFeature enum + bitmask for future #ifdef-driven permutations
- ShaderRegistry as future single source of truth for programs, with
  FNV-1a 64bit permutation cache and lazy compilation in get()
- ShaderPreprocessor for #define X injection between #version and source
- ShaderLoader: stateful include resolver with double-include guard and
  GLSL #line directives backed by an int file table
- App registers all sync shaders in registry parallel to ResourceManager
  (no GL compilation yet - registration is just path metadata)

Build: Snake3 + Tests green, ctest 16/16 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B2 of shader-system refactor. ShaderRegistry now produces real
permutation-cached programs with #define injection.

- ShaderLoader::loadShaderSource: public method returning #include-resolved
  source as string, so ShaderRegistry can preprocess before compile
- ShaderRegistry::get: removed assert(features==0); loads VS/FS strings,
  injects #define FEATURE_X via ShaderPreprocessor, compiles via
  bindFromBuffer. Vertex-only (transform feedback) shaders bypass
  preprocessor since particle updates don't use feature flags
- Smoke test in App::Init (IS_DEBUG only): verifies cache hit for same
  handle and cache miss across feature masks. Compiles basicShader twice
  extra under debug; this duplication disappears in B6 when registry
  replaces ResourceManager::addShader as single source of truth

B3 will add the matching `#ifdef FEATURE_*` blocks inside basic.fs so the
injected defines actually affect rendering. Until then, masked variants
compile to functionally identical programs.

Build: Snake3 + Tests green, ctest 16/16 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(B3a)

Phase B3a: wrap PBR/NormalMap/Shadows/IBL/Fog/DirectionalLight blocks in
basic.fs with `#ifdef FEATURE_*`. Else-branches that contain real
fallbacks (Phong, alphaBlending, regular point lights) are split into
separate `if (!cond)` so they stay available when the feature is off.

basicShader compilation moves from direct ShaderLoader to
ShaderRegistry::get with a legacy mask containing all features. With all
features active the new #ifdef blocks behave identically to the previous
runtime-only code. The duplication shrinks in B6 once materials build
through MaterialBuilder.

Discipline for B5+: C++ must not set xEnable=true on a shader that was
compiled without FEATURE_X - the if-branch would be stripped but the
!cond fallback would also skip, leaving the value uninitialised. The
upcoming MaterialFeature classes enforce this by only setting uniforms
when the feature is in the mask.

basic.vs, plane.fs and other shaders sharing basic.vs (respawnShader,
rainDrop) are untouched in this PR - they get migrated in B3b and B3d.

Build: Snake3 + Tests green, ctest 16/16 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`add_custom_target(copy_assets ALL ...)` only registers the target with
the default ALL build, so `cmake --build --target Snake3` skips it and
partial rebuilds keep running with stale shaders. Adding the explicit
dependency ensures every Snake3 build refreshes build/Assets.

Discovered while diagnosing a shadow regression that turned out to be
caused by partial rebuilds shipping with assets from a previous commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B3b: gate the skeletal vertex transform in basic.vs with
`#ifdef FEATURE_BONES`. Same safety-net pattern as basic.fs - the bone
path stays inside `#ifdef`, the non-bones fallback runs through a
parallel `if (!useBones)`.

- basic.vs: useBones default changes from true to false. With the new
  split, a shader compiled without FEATURE_BONES and a caller that
  forgets to set useBones would otherwise leave gl_Position unwritten
  (bones branch stripped, !useBones false). All current C++ paths set
  useBones explicitly (StandardMaterial::bind=false,
  AnimationArrayMesh=true, snake respawn=false), so the default flip is
  inert in practice.
- App.cpp: Bones added to legacyBasicFeatures so basicShader keeps both
  paths compiled.
- App.cpp: planeShader migrates from direct ShaderLoader to registry get
  with the same feature mask. plane.fs is untouched (its main() doesn't
  reference any FEATURE_*), but it shares basic.vs which now needs the
  Bones flag for the legacy vertex path.
- respawnShader and rainDrop stay async without features. Their callers
  set useBones=false, so the non-bones path is exercised exclusively and
  the bones-branch absence has no effect.

basic.fs and lights.glsl are unchanged. B3c will optionally tighten
lights.glsl (PBR/IBL function gating).

Build: Snake3 + Tests green, ctest 16/16 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B3c: wrap PBR/IBL function prototypes and bodies in lights.glsl
with `#ifdef FEATURE_PBR` / `#ifdef FEATURE_IBL`. Same defines pattern as
the call sites in basic.fs - when the feature is off the entire helper
disappears from the compiled program.

Affected functions:
- FEATURE_PBR: CalcDirLightPBR, CalcPointLightPBR, CalcSpotLightPBR
  (prototype only; impl was already commented out), fresnelSchlick,
  DistributionGGX, GeometrySchlickGGX, GeometrySmith
- FEATURE_IBL: CalcIBLSpecular, CalcIBLDiffuse

Non-PBR helpers (CalcDirLight, CalcDirLightMaterial, CalcPointLight,
CalcSpotLight, computePulse) stay unconditional - they're used by
non-PBR shaders too (shadow_map.fs, normal_map.fs, respawn.fs,
explosion.fs all include lights.glsl for these and the struct
definitions).

Verified none of the always-on lights.glsl consumers reference the
gated functions, so the #ifdef wrap is safe for legacy paths.

Build: Snake3 + Tests green, ctest 16/16 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B3d removes the plane.fs duplicate. plane.fs was a verbatim copy
of basic.fs with an extra hole-map discard block; now that basic.fs
supports feature gating, the hole map lives there too.

- ShaderFeature::HoleMap (bit 11), featureName "FEATURE_HOLE_MAP"
- basic.fs: hasHoleMap / holeMap uniforms declared unconditionally so
  C++ setBool/setInt calls always land somewhere; the actual discard
  branch is wrapped in `#ifdef FEATURE_HOLE_MAP` so non-plane shaders
  pay nothing
- App.cpp: planeShader is no longer a separate master. The
  ResourceManager alias stays for backward compat with
  MainScene::initPlane and PlaneMaterial - both still ask for
  "planeShader", but it now resolves to a basicShader permutation with
  legacyBasicFeatures | HoleMap
- Assets/Shaders/plane.fs deleted

PlaneMaterial::bind already binds holeMap to texture unit 8 and sets
hasHoleMap=true at runtime, so no code change is required there.

This is the first concrete payoff of the B refactor - 180+ lines of
duplicated shader replaced by a 13-line #ifdef block.

Build: Snake3 + Tests green, ctest 16/16 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B3e wires the snippet-injection infrastructure. No runtime
behavior change - the registry still doesn't call injectSnippet, that
happens in B5 once MaterialBuilder exists.

- basic.fs gets a `// @MATERIAL_FRAGMENT_PRE` marker at the top of
  main(). Until B5, it's just a GLSL comment so compilation is
  unchanged.
- ShaderPreprocessor::injectSnippet(source, marker, snippet) replaces
  the entire line containing the marker with snippet content. Adds a
  trailing newline if the snippet lacks one. Missing marker / empty
  snippet are silent no-ops. Only the first occurrence is replaced -
  later occurrences stay as markers for future call sites.
- Eight Catch2 cases in Tests/ShaderPreprocessorTests.cpp cover both
  injectDefines (which had no coverage) and injectSnippet edge cases.

Build: Snake3 + Tests green, ctest 24/24 passing.

Note: `file(GLOB Tests/*.cpp)` is evaluated at cmake-configure time, so
after adding a new test file `cmake ..` must run before the test list
picks it up. Builds without reconfigure silently skip new tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B4 lands the material composition API that B5 will start migrating
existing materials onto. Nothing in the render path uses these classes
yet - StandardMaterial / PlaneMaterial keep their current bind() shape
until B5.

New files:
- Renderer/Opengl/Material/RenderContext.h - struct that bundles
  viewPos/view/projection/model/uTime/shadows, replaces the 5-arg
  StandardMaterial::bind signature.
- Renderer/Opengl/Material/TextureSlots.h - central enum of texture
  units. Each feature reads its slot from here so collisions are caught
  by the unique-values test instead of by visual debugging.
- Renderer/Opengl/Material/Feature/IMaterialFeature.h - abstract:
  flag(), bind(shader, ctx), unbind(shader), clone().
- Renderer/Opengl/Material/Feature/HoleMapFeature.{h,cpp} - first
  concrete feature, owns one TextureManager, binds slot 8, mirrors the
  PlaneMaterial::bind hole-map block 1:1.
- Renderer/Opengl/Material/MaterialInstance.{h,cpp} - inherits
  BaseMaterial. Composes program + features list. bind(ctx) issues
  use(), writes the five core uniforms, then dispatches to each
  feature. clone() shares program (immutable GL handle) and deep-copies
  the feature objects (which themselves share textures).
- Renderer/Opengl/Material/MaterialBuilder.{h,cpp} - fluent API.
  useMaster() / with() accumulate, build(registry) ORs feature->flag()
  into a ShaderFeatureMask, asks the registry for that permutation,
  and wraps the program in a MaterialInstance.

Six Catch2 cases in Tests/MaterialBuilderTests.cpp cover the GL-free
parts: HoleMapFeature flag + clone (texture handle sharing), builder
accumulation order, featureMask OR-folding, nullptr ignore, and a
compile-time-ish guard that TextureSlots values are pairwise unique.

Build: Snake3 + Tests green, ctest 30/30 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B5a fills out the feature catalogue that B5c will use to rebuild
the plane via MaterialBuilder. No materials are migrated yet -
StandardMaterial / PlaneMaterial still own the render path.

New features under Renderer/Opengl/Material/Feature/:
- FogFeature: toggles fogEnable runtime. Flag: Fog.
- UvTransformFeature: uvScale + uvOffset. No flag.
- AlbedoFeature: albedo texture (slot 0) + optional color override +
  alpha + ambient intensity. Mirrors the "color half" of
  StandardMaterial::bind. No flag.
- NormalMapFeature: tangent-space normal (slot 1). Flag: NormalMap.
- SpecularFeature: specular highlight (slot 2) + shininess. No flag.
- ShadowFeature: CSM array sampler2DArray (slot 3) + shadowDepth
  shader. Crosses with RenderContext::shadows global toggle. Flag:
  Shadows.
- LightingFeature: directional + point + spot lights bundled. Flag is
  DirectionalLight only when a directional light is present - point /
  spot loops in basic.fs aren't gated, so adding their flags would
  fragment the permutation cache pointlessly.
- PlanarReflectionFeature: reflection texture (slot 20) + clipPlane +
  runtime enable. No flag for now; B8 converts the reflection
  composite into a snippet.

All concrete features inherit IMaterialFeature, store an explicit set
of shared_ptr resources, deep-copy themselves in clone() (shared
textures/lights, owned scalars). Each implementation mirrors the
matching slice of StandardMaterial::bind 1:1 so the B5c migration
becomes a behavior-preserving swap.

Fifteen GL-free Catch2 cases in Tests/MaterialFeaturesTests.cpp cover
flag() and clone() per feature, including clone-independence checks
(mutating original after clone doesn't leak into the copy).

Build: Snake3 + Tests green, ctest 45/45 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B5b plugs MaterialInstance into the existing render path. Each
draw now picks the binding strategy from the concrete material type:

  1. MaterialInstance (new B4 path) -> build a RenderContext and call
     instance->bind(ctx); features write their uniforms.
  2. StandardMaterial (legacy) -> the existing 5-arg bind() call.
  3. Anything else -> the raw-shader fallback that was already there.

unbind() mirrors the same dispatch so feature::unbind hooks run after
the draw.

uTime is sourced from glfwGetTime() at the call site; per-material
timers stay on StandardMaterial only. A single shared clock is cleaner
once MaterialInstance fully replaces StandardMaterial (B6+).

No materials currently are MaterialInstance, so the new branch is
unreachable at runtime - this PR is purely a wiring step. B5c will
flip the plane to use it.

renderShadowMap is untouched: only StandardMaterial casts shadows.
Plane is a flat receiver, so the upcoming B5c migration doesn't lose
anything. If a MaterialInstance-built mesh ever needs to write a
shadow depth pass, B6 adds an analogous dispatch keyed on
ShadowFeature::shadowDepthShader.

Build: Snake3 + Tests green, ctest 45/45 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B5c flips the floor from the legacy PlaneMaterial hierarchy to a
MaterialInstance built with MaterialBuilder. First real consumer of B4
and B5a.

- MainScene.h: planeMaterial is now shared_ptr<MaterialInstance>; two
  feature handles are kept (HoleMapFeature, PlanarReflectionFeature) so
  runtime mutation paths (per-level hole map rebuild, F2 reflection
  toggle) poke just the relevant feature instead of rebuilding the
  whole material.
- MainScene::initPlane composes nine features through MaterialBuilder
  (Lighting + Shadow + NormalMap + Specular + UvTransform + Fog +
  Albedo + PlanarReflection + HoleMap) and asks the registry for the
  matching basicShader permutation. PlaneMesh receives
  planeMaterial->getProgram() directly.
- MainScene::applyHolesToPlane and the F2 GLFW handler write through
  the feature handles.
- HoleMapFeature gains setTexture() so per-level applyHolesToPlane can
  swap the underlying TextureManager without rebuilding the material.
- ResourceManager::{set,get}ShaderRegistry forwards the registry that
  App owns to scenes; cleaner alternative to threading the registry
  through every scene ctor.
- App drops the planeShader alias - nobody calls
  getShader("planeShader") anymore; the builder asks the registry
  directly for basicShader | HoleMap.

Files removed:
- Renderer/Opengl/Material/PlaneMaterial.{h,cpp}
- Renderer/Opengl/Material/PlanarReflectionMaterial.{h,cpp}

Both classes were only used by the plane and now have no callers.

Build: Snake3 + Tests green, ctest 45/45 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B6a finishes the IMaterialFeature catalogue. With these three the
builder can express every material the engine currently uses.

- PbrFeature: metalness (slot 4) + roughness (slot 5) + aoMap (slot 7).
  Activates pbrEnabled at runtime so the Cook-Torrance branch in
  basic.fs runs. Flag: PBR.
  NOTE: preserves a pre-existing quirk in StandardMaterial::bind that
  sets `roughness` and `aoMap` as sampler names while basic.fs declares
  `roughnessMap` and `material.aoMap`. The mismatched setters
  no-op (location -1) so those samplers stay at default unit 0 and
  effectively read the albedo texture. We keep the same behaviour to
  avoid changing rendering during B6; a dedicated follow-up can
  normalise the names later.
- IblFeature: environmentMap cubemap (slot 6). Drives the IBL diffuse +
  specular branches in basic.fs (FEATURE_IBL inside FEATURE_PBR).
  Compositionally separate from PbrFeature so a material can opt into
  PBR without IBL.
- BonesFeature: useBones runtime toggle. Bone matrices themselves stay
  on the AnimationArrayMesh side, which pokes setMat4 directly per
  draw - that per-frame matrix stream doesn't fit the static feature
  shape. Flag: Bones.

Six new Catch2 cases cover flag() and clone() / share-resources for
each.

No material is migrated yet; B6b starts on SnakeMeshNode3D.

Build: Snake3 + Tests green, ctest 51/51 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B6b flips the snake body segments (tileMaterial) from
StandardMaterial to MaterialInstance composed via MaterialBuilder.
Head material (loaded from gltf), respawn / crash / headRespawn
materials (ShaderMaterial free-form) are intentionally untouched -
they fit different patterns and migrate later (B6c-d).

Design adjustments:
- LightingFeature::flag() now always returns DirectionalLight,
  regardless of whether a directional light is bound at build time.
  Without this, snake's pattern of "build the material at ctor, set
  directional later via setDirectionalLight" would compile the shader
  without the FEATURE_DIRECTIONAL_LIGHT block and dir lighting would
  never run on tiles. Test "LightingFeature flag depends on..." is
  rewritten as "always advertises DirectionalLight" with the new
  expectation. Cost: a few KB of compiled shader code per permutation
  that runtime-checks directionLightEnable.
- SnakeMeshNode3D: tileMaterial -> shared_ptr<MaterialInstance>;
  tileLightingFeature handle retained so set{Directional,Spot,Point}
  Lights propagate without rebuilding the material. Construction now
  uses MaterialBuilder with LightingFeature + ShadowFeature +
  AlbedoFeature(color=red).
- copyExplosionSourceMaterial helper extended to handle
  MaterialInstance: looks for an AlbedoFeature, extracts albedo /
  color, feeds them to the crash explosion shader the same way it
  used to from StandardMaterial::getColor()/getAlbedo(). Without this
  body segments would crash to a colourless explosion.
- createTileNode ternary needed an explicit base-class cast since
  MaterialInstance and ShaderMaterial no longer share a common
  shared_ptr type at the ?:.

RemoteSnakeScene / PlayerScene unchanged: those create the head
material (pacmanMesh) which stays StandardMaterial; B6c migrates the
loaders that produce it.

Build: Snake3 + Tests green, ctest 51/51 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…B6b follow-up)

Visual regression spotted after B5c: the plane looked fogged out in the
distance. Root cause is that the original StandardMaterial::bind
unconditionally writes shader.setBool("fogEnable", false) and the F
key toggle never reaches the shader (RenderManager::toggleFog only
flips a renderer-side flag that nothing reads back into a uniform), so
the floor effectively never had fog. B5c blindly mirrored the feature
list and added FogFeature(true), which writes fogEnable=true and
finally activates the FEATURE_FOG branch in basic.fs.

Fix: remove FogFeature from the plane builder chain. The plane goes
back to no fog, matching what was on screen before the migration. When
fog gets wired up properly (so the F toggle actually writes a uniform),
FogFeature will reappear here with a handle that scenes can mutate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B6c flips both box-material call sites from StandardMaterial to
MaterialInstance:

- BarriersScene::initBarriers: outer perimeter walls. Same feature set
  as before (Lighting + NormalMap + Specular + Albedo) with the
  ambient intensity tweak (0.1) routed through AlbedoFeature.
- LevelManager::createLevel: per-level interior brick walls. Adds
  AlbedoFeature::setColor({1,1,1}) to mirror the historical
  setColor white that originally activated overrideColorMesh - the
  AlbedoFeature path keeps the behaviour identical (since color = white
  multiplied with white albedo texture leaves output unchanged, but
  preserves the overrideColorMesh = true uniform that legacy code set).

Neither call site set a shadow texture in the old code, so no
ShadowFeature here. Boxes act as shadow casters (rendered into the
depth pass via StandardMesh::renderShadowMap if their material is
StandardMaterial)... which they're no longer. This may regress shadow
casting from walls; if so a separate fix wires renderShadowMap to also
handle MaterialInstance + ShadowFeature::shadowDepthShader. Plane
shadows on the floor still work because plane has ShadowFeature.

Build: Snake3 + Tests green, ctest 51/51 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B6d converts the three prop classes to MaterialInstance via
MaterialBuilder. Each prop holds feature handles for textures that
load lazily from gltf assets after init.

- BarrelNode3D: material -> MaterialInstance, with AlbedoFeature +
  NormalMapFeature handles for the post-init lazy texture binding in
  update(). LightingFeature + ShadowFeature complete the composition.
- StreetLampNode3D: three material instances (lamp body, glass shade,
  bulb) with full PBR + IBL on the body, PBR on the shade, and HDR
  emissive color on the bulb. Albedo / normal / pbr feature handles
  for each so update() can wire texture data once the gltf loader
  delivers it. mesh3 (bulb) keeps no shadow feature to match the old
  behaviour.
- TorchScene::initTorch: torchMaterial -> MaterialInstance with
  Lighting (dir + spots) + NormalMap + Albedo.

Feature setter additions to support lazy texture binding:
- NormalMapFeature::setTexture
- PbrFeature::setMetalness / setRoughness / setAoMap
- IblFeature::setEnvironmentMap

Build: Snake3 + Tests green, ctest 51/51 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B6e1 restores shadow casting for objects whose materials were
migrated to MaterialInstance in B5c/B6b/B6c/B6d. Until now
StandardMesh::renderShadowMap only handled StandardMaterial via
dynamic_cast - any other material type silently skipped the shadow
depth pass, so walls / barrels / lamp / torch / snake tiles stopped
contributing to the shadow map.

New surface:
- ShadowFeature::bindShadow(model): activates the depth shader and
  writes the model matrix. Returns false if no shadowDepthShader was
  supplied to the feature.
- MaterialInstance::bindShadow(model): walks features, delegates to
  the first ShadowFeature, returns its bool. No ShadowFeature -> not
  a caster, the mesh skips its shadow draw.
- StandardMesh::renderShadowMap dispatches MaterialInstance first, then
  falls back to the legacy StandardMaterial path. Both branches issue
  the same glDrawElements after binding.

Materials that already had a ShadowFeature (plane via B5c, snake tile
via B6b, barrel + streetlamp via B6d) immediately resume casting.
Materials without it (level boxes from B6c, torch from B6d, lamp bulb
mesh3) continue not casting - that mirrors their pre-migration setup
(no setShadow call) and is intentional.

Build: Snake3 + Tests green, ctest 51/51 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B6e2 extends the skeletal-mesh renderer so it can drive a
MaterialInstance-built material. Required before head materials can
migrate in B6e3 - until then no real material hits the new branch,
animation still flows through the legacy StandardMaterial path.

- MaterialInstance::getShadowProgram returns the ShadowFeature's
  shadowDepthShader (or nullptr). renderMesh uses it to know which
  program to write per-mesh `model` and bone matrices into during the
  shadow pass.
- AnimationArrayMesh::render gains a MaterialInstance branch before
  StandardMaterial. Bind builds a RenderContext (viewPos, view,
  projection, parentTransform, glfwGetTime, shadows) and delegates;
  unbind mirrors.
- renderShadowMap gains the same MaterialInstance-first dispatch.
  bindShadow returns false when no ShadowFeature is present so a head
  without shadow casting silently skips the draw.
- renderMesh resolves the active program once (main vs shadow) then
  writes finalBonesMatrices / useBones / model directly to it.
  StandardMaterial branch unchanged; bare-shader fallback unchanged.

Build: Snake3 + Tests green, ctest 51/51 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B6e3 swaps the pacman head materials in PlayerScene::initSnake
and RemoteSnakeScene::initSnake from StandardMaterial to
MaterialInstance. With B6e2 in place, AnimationArrayMesh::render now
flows through the MaterialInstance branch for skeletal head draws.

Mirror of the previous setup:
- LightingFeature: directional + point + spot lights (local
  DirectionalLight for the remote case)
- ShadowFeature: depth array + shadowDepthShader
- NormalMapFeature(nullptr): the head never explicitly received a
  normal texture - the legacy setNormalEnabled(true) call advertised
  the feature flag but with no texture bound, which `setBool` for
  normalMapEnabled left at runtime-true. AlbedoFeature(nullptr) keeps
  useMaterial=true so the gltf-baked vertex colors continue showing
  through.

Snake's headMaterial member is `shared_ptr<BaseMaterial>`, so no type
change required there - it just stores the MaterialInstance returned
by builder. copyExplosionSourceMaterial already handles MaterialInstance
(B6b) so the explosion effect picks up albedo / color from the head
feature pack when the head is the explosion source.

Build: Snake3 + Tests green, ctest 51/51 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B6e4 strips the remaining gameplay StandardMaterial usages:

- CoinScene::initCoin: gold coin material with PBR (metalness +
  roughness) + IBL (skybox cubemap) + NormalMap + Albedo. Spotlights
  go through a LightingFeature handle, replacing the
  StandardMaterial::addSpotLight loop with LightingFeature::setSpots
  after build.
- Physic shapes (Box / Sphere / Capsule / Cylinder): the debug-only
  translucent collision visualizers now build a MaterialInstance with
  a single AlbedoFeature (alpha=0.2). The per-frame red/cyan colour
  toggle goes through albedoFeature->setColor instead of the legacy
  StandardMaterial::setColor. A null-resourceManager guard is added
  for the test harness, which instantiates shapes with no managers.

After B6e4 the only remaining StandardMaterial referrer is
ShaderMaterial (which inherits StandardMaterial for the lights /
textures fields used by snake respawn / crash / ring / bolt / corner
custom shaders). Decoupling ShaderMaterial is a B7 cleanup task -
StandardMaterial itself will live on until that lands.

Build: Snake3 + Tests green, ctest 51/51 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Snake head rendered as half a skull with no animation after B6e3.
Root cause: head material composition didn't include BonesFeature, so
its feature mask resolved to DirectionalLight | Shadows | NormalMap,
and the registry compiled basic.vs without FEATURE_BONES.

basic.vs gates the bone transform branch with `#ifdef FEATURE_BONES`,
with a parallel `if (!useBones)` static fallback. Without the define
the bone branch is stripped. AnimationArrayMesh::renderMesh still
writes useBones=true for skeletal sub-meshes, which then turns
`if (!useBones)` false too - gl_Position is never written and vertices
end up at garbage positions.

Fix: add BonesFeature to head composition in PlayerScene::initSnake
and RemoteSnakeScene::initSnake. The feature flag advertises Bones,
shader compiles with FEATURE_BONES, and the per-mesh useBones toggle
from AnimationArrayMesh works as intended.

No other migrated materials need this: plane / level walls / props /
coin / snake body tiles / physics visualizers are all non-skeletal
and run useBones=false through the always-available static fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B7a strips the StandardMaterial inheritance from ShaderMaterial.
The two classes no longer share a base beyond BaseMaterial, paving the
way for B7b to delete StandardMaterial entirely.

What moved into ShaderMaterial as direct members:
- shader / shadowDepthShader (the two ShaderManager handles).
- albedo / normal / specular / metalness / roughness / shadow texture
  shared_ptrs and their setters/getters - that's the field surface
  SnakeMeshNode3D::copyExplosionSourceMaterial reads from the crash
  material.
- DirectionalLight / SpotLight / PointLight vectors and setters,
  matching how snake's setDirectionalLight propagates lights into the
  respawn/crash materials.
- color + alpha plus setBlending (which already comes from
  BaseMaterial's BlendingInterface) - no behavior change.
- New methods: unbind() (legacy texture-slot cleanup is a no-op now
  since ShaderMaterial doesn't manage well-known slots), bindShadow()
  (mirrors StandardMaterial::bindShadow), isShadowEnabled() and
  getShader / getShadowDepthShader accessors used by AnimationArrayMesh.

Mesh dispatch sites pick up a dedicated ShaderMaterial branch:
- StandardMesh::render and renderShadowMap try MaterialInstance first,
  then ShaderMaterial, then legacy StandardMaterial, then raw-shader
  fallback. Same for unbind.
- AnimationArrayMesh::render and renderShadowMap mirror the same chain
  for skeletal draws.
- AnimationArrayMesh::renderMesh resolves the active program for the
  current pass via MaterialInstance::getProgram or
  ShaderMaterial::getShader (and the shadow variants), then writes
  bone matrices / model / useBones to whichever shader the material
  declares. Legacy StandardMaterial fork untouched.

ShaderMaterial::clone now deep-copies its own state (color cloned,
shared_ptr fields aliased for textures/lights as before) instead of
relying on inherited copy.

Build: Snake3 + Tests green, ctest 51/51 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After B7a's ShaderMaterial decouple, the three 2D render sites still
only knew about StandardMaterial via dynamic_pointer_cast. ShaderMaterial
instances (menu buttons / corner shaders / text labels / image fx)
silently fell into the else branch and were drawn against the wrong
baseShader, producing a fully black scene with only the bolt flash
visible.

Fix: BaseNode2D, ImageNode2D and LabelNode2D each try ShaderMaterial
first, then StandardMaterial, then fall back to baseShader. unbind
paths in BaseNode2D and ImageNode2D mirror the same chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B7b removes StandardMaterial and IAlbedoMaterial. Everything in
the gameplay path renders through MaterialInstance now (B5-B6) and the
free-form custom shaders go through ShaderMaterial (B7a), so nothing
left referenced StandardMaterial except the dead dispatch fallbacks.

What went:
- Renderer/Opengl/Material/StandardMaterial.{h,cpp}
- Renderer/Opengl/Material/IAlbedoMaterial.h (only StandardMaterial
  implemented it; no other use)
- Three CMakeLists entries (executable, APP_SOURCES, snake3d_lib).
- Every `else if (StandardMaterial)` fallback branch in
  StandardMesh::render/renderShadowMap/unbind,
  AnimationArrayMesh::render/renderShadowMap and the three
  per-bone-matrix/useBones/model writes in renderMesh, and in
  BaseNode2D / ImageNode2D / LabelNode2D 2D dispatchers.
- SnakeMeshNode3D::copyExplosionSourceMaterial loses its
  StandardMaterial fallback - the head is a MaterialInstance after
  B6e3, so the MaterialInstance feature-pack walk is the only path
  the explosion shader needs.

Transitive include casualties (StandardMaterial.h used to pull these
in for callers): explicit `#include` added in
- Renderer/Opengl/Model/Standard/MeshNode3D.h: DirectionalLight,
  PointLight, SpotLight + `using namespace Lights` so derived classes
  like CoinMeshNode3D / SnakeMeshNode3D compile.
- Renderer/Opengl/Scene/Scene.h: Tools/Environment.h.
- App.h: Tools/Environment.h.
- StandardMesh.h swaps StandardMaterial.h for BaseMaterial.h since
  StandardMesh only needs BaseMaterial polymorphism now.

Build: Snake3 + Tests green, ctest 51/51 passing. No gameplay change
- the deleted branches were unreachable after B7a.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rRegistry (B8a)

Phase B8a wires snippet injection end-to-end through the build pipeline.
No real feature uses it yet - B8b's PlanarReflection conversion is the
first live consumer.

- ShaderHandle.snippets (marker -> .glsl path) joins master + features
  in the cache key. ShaderHandle::operator== compares snippets too, and
  makeKey hashes them deterministically by relying on std::map's
  ordered iteration.
- IMaterialFeature::snippetPaths() virtual returns an empty map by
  default - existing features keep behaving via `#ifdef FEATURE_X`,
  only features that need code-injection override.
- MaterialBuilder::build collects snippets from every feature in the
  composition before asking the registry for the program.
- ShaderRegistry::get loads each snippet's source (with `#include`
  resolution) and calls ShaderPreprocessor::injectSnippet on the
  vertex, fragment and geometry sources. Markers that don't appear in
  a given stage are silently skipped, so per-stage snippets fall out
  for free.

Three Catch2 cases lock the new surface:
- IMaterialFeature default snippetPaths is empty.
- MaterialBuilder picks up declared snippets from a fake feature.
- ShaderHandle equality distinguishes by snippets.

Build: Snake3 + Tests green, ctest 54/54 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B8b is the first live use of the B8a snippet pipeline. The
planar reflection block leaves basic.fs and lives in its own .glsl
file, injected by PlanarReflectionFeature.

- New Assets/Shaders/snippets/planar_reflection.glsl carries the
  if (reflectionEnable) blob that used to sit near the bottom of
  main(). Comments document the in-scope symbols the snippet expects
  (clipSpacePos, FragColor, calcReflexion from reflection.glsl,
  rainDropEnable + rippleOffset still on the basic.fs side).
- basic.fs replaces the blob with `// @MATERIAL_FRAGMENT_POST` marker.
  When a material doesn't add PlanarReflectionFeature, the marker
  stays as a plain comment and the program runs without the
  reflection composite.
- PlanarReflectionFeature::snippetPaths returns
  {@MATERIAL_FRAGMENT_POST -> snippets/planar_reflection.glsl}.
  ShaderRegistry's cache key now treats reflective and non-reflective
  permutations as distinct programs (cheaper than runtime branching
  and feeds nicely into the bigger story of C phase named placeholders).

Visual result: plane's reflection (the only consumer of the feature
today) keeps working through F2 toggle - PlanarReflectionFeature::bind
still flips reflectionEnable at runtime. Other materials never had
the feature in their builder chain, so they're untouched.

Build: Snake3 + Tests green, ctest 54/54 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final B8 step: the rain ripple distortion in basic.fs migrates from the
runtime `if (rainDropEnable)` pattern to the regular feature-flag
pattern used by everything else (PBR, NormalMap, Shadows...).

Why feature flag instead of snippet: the ripple touches three sites in
main() (compute rippleOffset, perturb tangent normal, derive normal
from ripple when no normal map). Splitting that across snippet
placeholders would need 2-3 markers, all interleaved with feature
blocks. A flag is simpler and consistent with the rest of the
gate-by-#ifdef family.

- ShaderFeature::RainRipple (bit 12) + FEATURE_RAIN_RIPPLE define.
- basic.fs wraps the three rainDropEnable blocks in
  `#ifdef FEATURE_RAIN_RIPPLE`. The rippleOffset local declaration
  stays unconditional because the planar reflection snippet from B8b
  reads it - without the feature it just stays vec2(0.0) and the
  snippet's `if (rainDropEnable)` skips the distortion at runtime
  (rainDropEnable is still a uniform that defaults to false).
- New RainRippleFeature header-only class follows the FogFeature
  pattern: scalar params (enabled, speed, density) plus flag() and
  bind(). Dormant in this commit - no material adds it to its
  composition. Future weather work can drop it into the plane chain
  to bring the effect online.

No gameplay change today: `rainDropEnable` was never wired from C++
side, so the gated code was already dead. The shader is now smaller
for materials that don't request the feature.

Build: Snake3 + Tests green, ctest 54/54 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ViPErCZ and others added 12 commits May 18, 2026 22:23
DepthMapRenderer::render used to write lightSpaceMatrix0/1/2,
cascadeEnds0/1/2 and shadowCenter only on ResourceManager's
"basicShader" entry. After the B refactor each material gets its own
program permutation (basicShader|features, basicShader|features|HoleMap,
basicShader|features|Bones, ...), so the plane / snake / props all
held their own program IDs and the per-frame matrices never landed on
them. Shadow sampling against an uninitialised matrix0..2 produced the
no-shadows-on-plane regression the user reported.

Fix: iterate ShaderRegistry::cachedPrograms() and write the shadow
uniforms on every cached program. Programs that don't have those
uniforms (particle update, 2D, skybox, ...) silently no-op since
glGetUniformLocation returns -1. Performance is fine: ~20 cached
programs * 7 setX calls per shadow render is negligible.

ShaderRegistry exposes cachedPrograms() as a read-only getter for this
kind of broadcast use case. Hot reload (later C-phase) will probably
clear the cache through clearCache() and re-emit the broadcast on next
frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PBR materials (coin, street-lamp post) were getting Phong-style
spotlight contribution. CalcSpotLight reads material.shininess, which
PbrFeature doesn't set - it defaults to 0, and pow(specAngle, 0) = 1
across the whole hemisphere, so highlights spread into bright smudges
under street lamp / torch spots.

- lights.glsl gets a proper CalcSpotLightPBR implementation: same
  Cook-Torrance kernel as CalcPointLightPBR plus the spot cone falloff
  and pulse modulation lifted from CalcSpotLight. Lives under
  #ifdef FEATURE_PBR like the other PBR helpers (B3c). The old
  commented stub is gone, the prototype updated to the new signature.
- basic.fs branches the spotlight loop on pbrEnabled, mirroring how
  point lights were already split.

`normal`, `ambient`, `roughness`, `metalness`, `F0` are all in main()
scope from earlier in the function, so the PBR loop just plumbs them
into the new helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CoinScene used AlbedoFeature's default ambientIntensity (0.05), which
is fine for diffuse materials but starves a metallic + IBL surface.
With metalness ~= 1 the diffuse path goes to zero, leaving the coin's
ambient lit only by the cubemap reflection at R = reflect(-viewDir,
normal). As the coin spins, R sweeps the skybox and lands on dark
faces (cloud-skybox bottom), making the coin briefly go black before
the proper spot/dir contribution kicks back in.

Setting AlbedoFeature::setAmbientIntensity(2.0f) gives the reflection
a baseline brightness multiplier so the coin stays readable across the
rotation. 2.0 lands between the default 0.05 and the 2.5 the street
lamp post uses; visually balanced per playtest.

This was masked before c77d2a8: the Phong CalcSpotLight running on a
PBR material was bogus-bright (material.shininess defaulted to 0 so
pow(spec, 0)=1 everywhere) and made the coin look brighter than it
should. The PBR-correct spot light exposed the underlying ambient
shortfall.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B7c: rename Manager::ShaderManager to Manager::ShaderProgram
across the codebase. The class wraps a GL program handle; "Program"
is the standard GL terminology and avoids the confusion with the
other *Manager helpers (ResourceManager, RenderManager,
KeyboardManager, ...).

What moved:
- Manager/ShaderManager.{h,cpp} -> Manager/ShaderProgram.{h,cpp}
  (renamed via git mv so blame survives).
- Header guard SNAKE3_SHADERMANAGER_H -> SNAKE3_SHADERPROGRAM_H.
- Class declaration and every member-function definition.
- Every callsite across the engine (~102 files): includes, type
  names, shared_ptr<ShaderProgram>, etc. Driven by a single
  word-boundary sed pass; only `ShaderManager` appears in the
  codebase (no aliases, no near-matches), so the global replace is
  safe.
- CMakeLists.txt entries (executable inline list, APP_SOURCES,
  snake3d_lib reuses APP_SOURCES so only two edits in practice).

Mechanical rename only - no behavior change. Build passes, ctest
54/54 still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
H1-H4a phase work and performance optimization (FPS 11 -> 44):

Engine/game split:
- CMakeLists.txt refactored into three targets: snake3d_engine (static lib),
  snake3d_game (static lib, Snake3-specific scenes/nodes/netcode), Snake3
  (executable shell). Tests links engine+game.
- Game code physically moved under examples/snake3/src/ (Scenes, game-side
  Renderer/Opengl/Model/Game, Network/Game, LevelManager/EatManager,
  SnakeMove/EatLocation/Radar handlers, App, main). Assets moved to
  examples/snake3/Assets.
- Bulk include rewrite via basename lookup; engine paths become
  root-relative.
- Removed dead engine->game couplings (TextureLoader.h pulling EatManager,
  KeyboardManager.h pulling SnakeMoveHandler).
- IMaterialFeature.h: removed top-level using-namespace pollution.

Performance (FPS 11 -> 44):
- ShaderProgram uniform location cache (no per-set glGetUniformLocation).
- CollisionSystem3D: cached enable/layer/mask in entry, static/dynamic pair
  split (3M -> 24k pairs/frame).
- Static collider reclassification in BarriersScene/TorchScene (140
  perimeter walls + barrel + 4 torches flipped from dynamic to static).
- MeshNode3D transform-dirty short-circuit in computeWorldMatrix (2300+
  floor cells skip recompute).

Dev tools:
- D4 pre-flight: ShaderRegistry::warmupAll compiles base permutations at
  bootstrap, fatal abort on failure.
- ImGui Object Inspector: unified select across Position/Scale/Rotation/
  Collision/Lights handlers; per-type fieldset (transform always, light
  colors + direction, collision layer/mask); Focus camera + Print buttons;
  default position no longer overlaps Engine panel.
- ImGui Shader Inspector (POC): list cached programs, watched paths
  tooltip, per-program Reload, force F10 hot reload.
- RenderStats with per-frame draw counter (per-pass split), main pass
  CPU/GPU timing, update/physics/render/swap phase timing, physics
  breakdown (aabb/pairs/colliders).
- Camera: focusOn(target) one-shot teleport without sticky bind; mouse
  rotation gated to Ctrl/RMB in debug so ImGui sliders are usable.

CI:
- .github/workflows/ci.yml: Linux Ubuntu Debug + Release matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant