Wire the existing LightManager + WorldTimeService state into visible
rendering. Every draw call (terrain, static mesh, instanced mesh, sky)
now shares one SceneLighting UBO at binding=1 carrying:
- 8 Light slots (Directional / Point / Spot, retail hard-cutoff)
- Ambient RGB + active light count
- Fog start/end/mode + color + lightning flash scalar
- Camera world position + day fraction
The CPU side (SceneLightingUbo in Core.Lighting) is a POD struct that
gets BufferSubData'd once per frame from GameWindow.OnRender. Shaders
read the block via `layout(std140, binding = 1) uniform SceneLighting`
— no per-program uniform uploads.
Shader changes:
- mesh.frag + mesh_instanced.frag accumulate 8 dynamic lights per
fragment using the retail no-attenuation hard-cutoff model
(r13 §10.2 / §13.1). Sun reads slot 0; spots use hard cos-cone test.
Additive lightning flash + linear fog layered on top. Saturate
clamps per-channel to 1.0.
- terrain.vert bakes AdjustPlanes sun+ambient per vertex using the
retail MIN_FACTOR = 0.08 ambient floor (r13 §7). terrain.frag adds
fog + flash on top of the baked vertex color.
- mesh.vert + mesh_instanced.vert emit vWorldPos so the fragment
stage can do per-pixel lighting against world-space positions.
- New sky.vert / sky.frag pair — unlit, scroll-UV, camera-centered,
with its own 0.1..1e6 far plane. Ports WorldBuilder's skybox.
SkyRenderer (new file in App/Rendering/Sky/) ports WorldBuilder's
SkyboxRenderManager verbatim for the C# idiom: zeroed view translation,
dedicated projection, depth mask off, iterate each visible SkyObject
in the day group, apply arc transform (Z rot for heading + Y rot for
arc sweep), feed TexVelocityX/Y as a scrolling UV offset, apply
per-keyframe SkyObjectReplace overrides (mesh swap + transparency +
luminosity) for overcast / dusk cloud variants.
GameWindow integration:
- OnLoad parses Region (0x13000000) into LoadedSkyDesc and hot-swaps
WorldTime's provider to the dat-accurate keyframes. Seeds to noon
for offline rendering. Creates the SceneLightingUboBinding and the
SkyRenderer.
- OnRender: set clear color from atmosphere fog, tick WeatherSystem,
spawn/stop rain/snow camera-local emitters on kind change, feed
sun to LightManager (zero intensity indoors — r13 §13.7), tick
LightManager against viewer pos, build + upload the UBO, draw
sky before terrain, draw terrain + static + instanced using the
shared UBO.
5 new UBO packing tests (struct sizes, slot population, 8-light cap,
directional slot 0).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three separate fixes landed today, each addressing a specific bug the
user observed during live play:
1. NPC clothing changes by camera angle (InstancedMeshRenderer)
- Group key was (GfxObjId) only, so every humanoid NPC using the
same body mesh piled into one instance group; only the first
instance's texture was used for the entire DrawInstanced batch,
so which NPC's palette "won" changed as frustum culling and
iteration order shuffled entries.
- Now keyed by (GfxObjId, PaletteHash ^ SurfaceOverridesHash)
so only compatible instances batch; each unique appearance gets
its own draw call. Perf hit is small (humanoid NPCs each emit
one more draw call); visually every NPC is now stable.
2. GpuWorldState dedup on respawn
- Server re-sends CreateObject for the same guid on visibility
refresh / landblock crossing / appearance update. AppendLiveEntity
was blindly appending each time, so GpuWorldState accumulated
multiple copies of the same entity, each with its own
PaletteOverride / MeshRefs. That alone wasn't the clothing bug
(that was #1) but it would have caused other overlap problems
downstream.
- Added RemoveEntityByServerGuid + WorldGameState.RemoveById;
OnLiveEntitySpawnedLocked calls both before creating the new
entity so respawns replace cleanly.
3. Motion wire format — run animation sync with retail observers
- ACE's MovementData constructor only computes interpState.ForwardSpeed
on the WalkForward/WalkBackwards branch; every other ForwardCommand
falls into `else` and passes through WITHOUT speed set, giving
observers speed=0. Sending RunForward directly meant retail
clients saw us "run in place" while position drifted forward.
- Wire: always WalkForward + HoldKey.Run for running. ACE
auto-upgrades to RunForward with creature.GetRunRate() for
broadcast — correct command + correct speed at observers.
- Added per-axis FORWARD_HOLD_KEY / SIDE_STEP_HOLD_KEY /
TURN_HOLD_KEY so every active axis carries HoldKey.Run when
running (matches holtburger's build_motion_state_raw_motion_state).
- Added LocalAnimationCommand to MovementResult so our own
client still plays the RunForward cycle locally while the wire
stays WalkForward. Wire vs. local animation command are now
decoupled.
- Walk-backward wire command changed from WalkForward@-0.65 to
WalkBackward@1.0 (holtburger pattern).
- Strafe speed changed from 0.5 to 1.0 on wire AND local physics
(matches retail sidestep pace).
4. Jump height default + env-var tuning
- Default jumpSkill bumped from 100 → 200 (jump ≈ 3m at full
charge, closer to retail feel for a mid-level character).
- ACDREAM_RUN_SKILL and ACDREAM_JUMP_SKILL env vars now override
the defaults so the user can tune per-character until we parse
PlayerDescription and plumb real skill values through.
5. JustLanded signal on MovementResult
- Tracks airborne→grounded transition so future animation code
can fire the landing cycle when we land. Just a bool flag for
now — no consumer yet (the proper action-queue path will use it).
Not in this commit: jump animation itself. An earlier attempt to
SetCycle(Jump=0x2500003b) fed an Action-type motion into the SubState
cycle resolver, which produced a "torso" mis-render. Reverted. The
proper fix is porting the retail motion action-queue semantics into
AnimationSequencer — see docs/research/deepdives/r03-motion-animation.md
for the spec. That's the next session's work.
470 tests pass, build clean.
Port ACME's EnvCellManager portal visibility system:
- New CellVisibility class: BFS portal traversal from camera cell,
portal-side clip-plane test, FindCameraCell with grace period
- LoadedCell data populated during streaming (portals, clip planes,
world/inverse transforms, local AABB from CellStruct vertices)
- WorldEntity.ParentCellId tags interior entities for filtering
- InstancedMeshRenderer.Draw accepts optional visibleCellIds set —
interior entities whose parent cell isn't visible are skipped
- Conditional depth clear between terrain and static mesh when
camera is inside a cell (ACME GameScene.cs pattern)
When camera is outdoors, all interiors render (visibleCellIds=null).
When camera enters a building, only BFS-reachable cells render.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace guessed sun direction (0.5, 0.4, 0.6) with ACME's verified
value (0.5, 0.3, -0.3) from GameScene.cs:238. Replace hardcoded
ambient/diffuse (0.25/0.75) with ACME's ambient intensity 0.45 from
LandscapeEditorSettings.cs:108.
Terrain shaders now match ACME Landscape.vert/frag pattern:
- Vertex shader computes Lambert term with xLightDirection uniform
- Fragment shader applies: color * (clamp(lambert, 0, 1) + xAmbient)
Static object shader matches ACME StaticObject.vert:
- LightingFactor = max(dot(N, -L), 0) + ambient
- Removed separate uDiffuseIntensity (ACME doesn't have one)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the per-entity glUniform uModel path with a shared instance VBO and
DrawElementsInstanced. All instance model matrices are uploaded to GPU once per
frame; the VAO's per-instance attribute pointers (locations 3–6, divisor=1) are
updated with a byte-offset re-point per group so a single VBO serves all groups
without requiring DrawElementsInstancedBaseInstance (not in Silk.NET 2.23).
Changes:
- InstancedMeshRenderer: add _instanceVbo, _instanceBuffer scratch; EnsureUploaded
sets up mat4 instance attrs (locs 3–6) from the shared VBO; Draw builds the flat
float[] of all instance matrices once then calls DrawElementsInstanced per sub-mesh.
Drops the unused uint TerrainLayer attribute (loc 3 from vertex VBO) — mesh shaders
never used it. Adds InstanceGroup helper to track per-group buffer offsets.
- mesh_instanced.frag: replace sampler2DArray+uTextureLayer with sampler2D uDiffuse,
matching the existing TextureCache / individual-texture pipeline.
- mesh_instanced.vert+frag: track as committed files (were untracked).
- Shader.cs: add SetVec3 helper needed for uLightDirection uniform.
- GameWindow.cs: switch mesh shader load from mesh.vert/.frag to
mesh_instanced.vert/.frag.
Visual output is identical: same entities, same textures, same lighting constants
(SUN_DIR=(0.5,0.4,0.6), AMBIENT=0.25, DIFFUSE=0.75 — moved from frag to vert).
Build: clean. Tests: 431/431 green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Groups all (entity, meshRef) pairs by GfxObjId before drawing so each
GfxObj's sub-meshes are processed as a contiguous batch. Still uses
per-entity uniform uModel — visual output is identical to the old
StaticMeshRenderer — but the _groups dict is the structural prerequisite
for swapping to DrawElementsInstanced in the follow-up commit.
Key changes:
- New InstancedMeshRenderer.cs with CollectGroups() that fills
_groups[gfxObjId] = List<InstanceEntry> each frame, reusing the
inner List<> objects to avoid per-frame allocation.
- Same two-pass (opaque+clipmap first, translucent second) draw logic
from StaticMeshRenderer, now iterating over groups rather than raw
entity/meshRef pairs.
- GameWindow.cs: field and constructor swapped from StaticMeshRenderer
to InstancedMeshRenderer — public API is identical.
- 431 tests green, 0 warnings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>