acdream/docs/research/2026-08-21-terrain-and-atmospheric-rendering-findings.md
Erik 6e76314b08 docs(vm4): every Campaign AR document says what its evidence shows
Dated 'VM4 correction' notes, not silent rewrites:
- AR plan: NoOpRenderPackProductionIntegrationTests is a 2x2 recording-
  device fixture, not a production pin; the real invariance is VM0's.
  Both '14,928/14,928 zero skips' totals qualified: hermetic lane filter.
- Stage-2 connected report: the dense-town CPU figures ran under the
  automation observer (9.8 MB/frame alloc) and say nothing about the
  owner's hitch on the product; clean numbers are VM0's.
- Track A report + findings doc: TerrainUtils.GetNormal only orients
  scenery; the render normals were already smooth; A2 replaced central
  differences with retail's incident-face average (the reviewer's own
  premise error).
- Findings doc: 'retail brightens' is the fallback path only; real
  hardware runs the single-pass lerp (VM2). LandscapeDetailTextures = 0
  answers the landscape open question. VM0 production table added as the
  baseline.
- Completion audit retail-path row and the review's F1/F2/F3/F5 headers
  annotated with their closures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:51:59 +02:00

17 KiB
Raw Blame History

Terrain detail, terrain normals, and atmospheric rendering — verified findings

Date: 2026-08-21 · Status: RESEARCH / HANDOFF — no code written Tree: main @ 255b0aae (branch claude/git-sync-status-5fb1d2, level with main)

Everything below was measured against the installed DATs, the current source tree, or the named-retail decomp. It exists so the next session does not re-derive it. Where a claim was tested and refuted, that is recorded too — those are the expensive ones to rediscover.


1. The detail-texture overlay is NOT implemented (and the setting lies)

Renderer: terrain_modern.frag / .vert contain zero detail references — no second sampler, no detail UV, no distance-fade term. Tree-wide there is no DetailTex / detail-surface concept in src/AcDream.App/Rendering/. Verified at 255b0aae, not only at the older bb1640f7.

But the SETTING exists and is DEAD:

Piece Location
bool BuildingDetailTextures = true AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs:77
persisted as "buildingDetailTextures" SettingsStore.cs:99 (read), :677 (write)
Options checkbox, retail string ID_Graphics_BuildingDetailTextures ConfigOptionsPageController.cs:772-774
consumers in Rendering/ none

The Options window shows "Building Detail Textures", checked by default, persisting across sessions — wired to nothing. This is worse than not having the setting: the UI implies a feature that does not exist. #226's port must consume this existing setting, not invent a new one, and its acceptance test must include "toggling it visibly changes the scene".

Already shipped and NOT to be confused with this: #155 base tiling (bb5acab9, ported TexMerge::CopyAndTile/Merge) — that fixed the stretched look. #155 and #226 were conflated once already.


2. How retail's detail pass actually works

Authored data (measured from the installed DATs)

Detail textures are authored per terrain entry: TerrainTex.DetailTextureId + TerrainTex.DetailTexTiling, reached via LandSurf::GetDetailTex / GetDetailTiling.

Region 0x13000000 "Dereth": 33 terrain entries, only 3 distinct detail textures.

Detail texture RenderSurface Size Used by
0x050012AF 0x060037D2 64x64 A8R8G8B8 29 of 33 (88%)
0x05001786 0x06006D57 256x256 A8R8G8B8 2 — BarrenRock, LushGrass
0x05001787 0x06006D58 256x256 A8R8G8B8 2 — Grassland, Ice

DetailTexTiling is mostly 1 (4 for grass/rock types, 8 for FauxWaterRunning, 2 for RoadType) against a base TexTiling of 2.

This confirms the community observation that the client "uses the same noise texture for everything" — one 64x64 texture backs 88% of Dereth's terrain.

Four categories, and which the setting actually gates

LScape::SetDetailTexturing (0x00506b40) manages four independent detail surfaces + tilings: 0 = landscape, 1 = building, 2 = environment, 3 = object. Also GenerateDetailSurface (0x00506230), CleanupDetailSurfaces (0x00504ae0).

LScape::ChangeRegion (0x00506cb0) calls:

SetDetailTexturing(this, 0, EnvDetail, EnvDetail, 0);
//                       ^landscape=OFF ^building ^environment ^object=OFF

gated on the single Render::m_RenderPrefs.EnvironmentDetailTextures. So retail's setting is a BUILDINGS-AND-INTERIORS setting, not a terrain setting — which explains why its UI label is "Building Detail Textures".

SmartBox::SetDetailTexturing (0x00451df0) can pass landscape through. ANSWERED 2026-08-22 (VM2 cdb read): Render::m_RenderPrefs.LandscapeDetailTextures is a real, separate preference and reads 0 on the live client (EnvironmentDetailTextures = 1); landscape_detail_surface is null while building_detail_surface/environment_detail_surface are set. Terrain detail is off by preference, and there is no Options row for it.

The blend — the FALLBACK path (see the correction below)

ACRender::SetDetailSurfaceInternal (0x006b6280):

SetStageTexture(stage, detailTex);
SetSamplerAddressMode(stage, TEXADDRESS_WRAP, TEXADDRESS_WRAP);
SetSamplerFilterMode(stage, TEXFILTER_LINEAR x3);
if (stage == 0) {
    SetBlendFunction(curr_detail_src_blend, curr_detail_dst_blend, BLENDOP_ADD);
    SetAlphaBlendEnable(1);
    SetDepthBufferMode(DEPTHTEST_LESSEQUAL, 1);
}

It is a framebuffer blend, not a DOT3/normal-map path. Factors differ per category:

Path site src dst
environment / interiors 0059f1c2 9 = BLEND_DSTCOLOR 6 = BLEND_INVSRCALPHA
landscape 005a19a1 5 = BLEND_SRCALPHA 6 = BLEND_INVSRCALPHA
static default (.data) 0081eca0 5 6

DSTCOLOR + INVSRCALPHA under BLENDOP_ADD evaluates to dest x (detailLum + 1 - alpha). Decoded means of the three real textures:

texture lum alpha factor effect
0x060037D2 0.459 0.282 1.177 +18% brighter
0x06006D57 0.414 0.210 1.204 +20% brighter
0x06006D58 0.165 0.132 1.033 +3% brighter

The FALLBACK detail pass brightens rather than roughens — and, CORRECTED 2026-08-22 (VM2/VM4), the fallback is not what real hardware runs. The framebuffer blend above is taken only when stage == 0, i.e. when the adapter cannot advertise D3DTEXOPCAPS_PREMODULATE. A live cdb read on the owner's AMD GPU gave m_caps.bCanDoSinglePassDetailing = 1 and trysinglepass = 1, so retail uses the single-pass texture-stage path in D3DPolyRender::SetSurface (0x0059c4d0): lerp(base * diffuse, detail.rgb, detail.a * diffuse.a) — a mild blend toward the detail colour (about 10 % on mid-tones with the live category texture). The community's "reflect more instead of being rougher" describes the fallback only. Full evidence: 2026-08-22-vm2-retail-detail-path-cdb.md. The #226 port was re-done to the single-pass math at Campaign VM VM1.

Terrain draw path

ACRender::landPolyDrawtwo overloads, 0x006b6320 (single-poly) and 0x006b6760 (two-poly). Detail is gated on trysinglepass && m_caps.bCanDoSinglePassDetailing && curr_detail_surface != 0, then SetDetailSurfaceInternal(1). Single-pass multitexture, with a non-single-pass fallback that is untraced. Vertex lighting comes from ACRender::curLandBlockVertexLighting.


3. REFUTED claims — do not re-chase these

  • "client_highres.dat is an override dat and we pick the low-res copy." FALSE. Measured twice, including at 255b0aae: portal 20,684 RenderSurfaces, highres 2,294, overlap 0 — fully disjoint IDs. TextureCache (Portal.TryGet || HighRes.TryGet) and DatCollectionAdapter.TryResolvePreferred (Portal then HighRes) both reach them, so every high-res surface resolves. We already use them. The "Portal precedence is load-bearing" comment in DatCollectionAdapter is vestigial given zero overlap. QUESTION CLOSED.
  • "The detail textures are normal maps." FALSE. The 64x64 one is blue-dominant (RGB 0.303/0.469/0.824), which looks like a tangent-space normal map, but unpacking RGB as a vector and measuring length gives 24.3% / 7.7% / 14.9% unit-length pixels (a real normal map is ~100%). They are colour+alpha detail textures.
  • "AC has no normal-map capability at all." ALSO FALSE — the engine has a BumpMap parameter, the DotProduct3 texture op, and a hardware capability probe m_caps.bTexOpDotProduct3 (read at 0x0059f5c7). The machinery exists; the detail textures simply are not normals. What uses the bump path is untraced.

4. Terrain geometry and normals

  • Landblock = 192 m; 8x8 cells @ 24 m; 9x9 = 81 height samples; 2 triangles per cell => 128 triangles per landblock.
  • Heights are quantised: each sample indexes a 256-entry region.LandDefs.LandHeightTable. This caps what smoothing or subdivision can achieve — they smooth quantisation, they do not recover unsampled detail.
  • Diagonal split direction comes from the FSplitNESW hash of world cell coords (constants 0x0CCAC033, 0x421BE3BD, 0x6C1AC587, 0x519B8F25).
  • src/AcDream.Core/Rendering/Wb/TerrainUtils.cs GetNormal returns the flat per-face normal. CORRECTED 2026-08-22 (VM4): that function only orients procedural scenery (SceneryGenerator.cs:166); it never fed the render normal, and the claim that it produced a faceted look was wrong. The rendered mesh already used smooth central-difference normals (Phase 3b, LandblockMesh). Retail (CLandBlockStruct::calc_lighting 0x00531700) averages the unit plane normals of each vertex's incident polygons; Campaign AR's A2 ported that, replacing central differences — a real parity port with a subtle visual delta (VM0: 1114 % of pixels at mean |Δ| ≈ 11.5).

Open question, must be answered from the decomp, no guessing: does retail smooth terrain vertex normals? Entry points: ACRender::landPolyDraw (both overloads, note ACRender::curLandBlockVertexLighting), LScape::calc_object_light (0x00455730), the LScape sunlight/ambient block ~0x00455b51, and how a terrain CPolygon's vertex normals/colours are built. TerrainUtils is WorldBuilder-derived, so WB may have simplified — check retail, not WB.

  • retail smooths => parity gap, schedule like #226.
  • retail is faceted => opt-in enhancement, belongs with the shader-pack work.

Smoothing costs zero geometry change and therefore zero physics risk: collision, walkability, slope tests and the 4M-cell conformance sweep all see identical triangles.

Subdivision is NOT recommended and the next session should try to refute rather than confirm that: perceived softness is texture-frequency (§1-2) plus flat shading (this section), not silhouette resolution; the 256-step quantisation caps the gain; and it is the only one of the three that touches the physics contract — those triangles ARE the collision surface (FloorZ / ValidateWalkable, walkable-polygon tracking, precipice/cliff slide, the 4M-cell conformance sweep, and the triangle-boundary Z bug that cost five failed fix attempts). If ever wanted, the only defensible forms are coplanar surface-preserving subdivision (identical surface, better Gouraud gradients) or an interpolating spline through the original 81 samples, render-only, with a registered bounded divergence.


5. Renderer state relevant to atmospheric work

  • 9 shader pairs (debug_line, mesh_modern, particle, particle_mesh, portal_depth, sky, terrain_modern, ui_text, vk_probe) plus a compiled spv/ directory.
  • mesh_modern uses per-vertex Gouraud lighting deliberately — the A7 comment records that a per-pixel evaluation produced a hard "spotlight pool" unlike retail's fixed-function T&L. 8-light SceneLighting UBO carrying uFogParams / uFogColor / uCameraAndTime. Two-pass alpha (opaque discard <0.95, translucent discard >=0.95 and <0.05).
  • Sun direction already exists and already tracks the day/night cycle: WorldRenderFrameBuilder.cs:526-SkyStateProvider.SunDirectionFromKeyframe(keyframe), supplied as LightKind.Directional (:531, :546). The sun is a real drawn object (dayGroup.SkyObjects via SkyPesFrameController).
  • A depth-only pipeline shape already exists: portal_depth.vert/.frag with GpuPipelineDescription.ColorWrite = false (Gpu/GpuPipelineDescription.cs:276, honoured in Gpu/Vk/VulkanGpuPipeline.cs:205) and an empty fragment main(). A shadow map is that pipeline aimed at the sun.
  • activeDayGroup (weather: Clear / Cloudy / Overcast / Rainy) reaches the frame builder.
  • Campaign V (OpenGL -> Vulkan) closed 2026-07-29: pass-based RHI (IGpuDevice / IGpuFrame / IGpuPassEncoder, explicit GpuPassDescription + GpuPipelineDescription) over bindless + MDI.

Performance baseline and the binding constraint

519.7 FPS; CPU/GPU p50 1.869 / 1.096 ms (CLAUDE.md), and the dense-town profile is CPU-SUBMISSION-BOUND (memory: feedback_render_perf_measurement).

VM0 baseline (2026-08-22, connected, uncapped Release, no automation observer, ACDREAM_FRAME_PROF=1; 2026-08-22-vm0-default-path-invariance.md):

Spot Binary CPU p50 / p95 GPU p50 alloc KB/frame
Holtburg 6c79d35c 4.7 / 5.1 ms 0.4 574
Holtburg Campaign AR, pack off 4.1 / 4.4 ms 0.4 21
Arwic (dense) 6c79d35c 6.0 / 6.5 ms 0.7 582
Arwic (dense) Campaign AR, pack off 5.2 / 5.6 ms 0.7 29

Any figure captured under ACDREAM_AUTOMATION_ARTIFACT_DIR carries the render-scene observer's allocation and is NOT comparable with this table.

Consequences:

  • GPU-side fullscreen work is nearly free in observed FPS — it fills GPU idle time. Tier-1 post-processing costs little.
  • Anything that adds CPU submissions is expensive. Shadow cascades must not re-run visibility culling per cascade on the CPU — draw the full resident set per cascade, or move culling to a GPU compute pass.
  • glFinish-style profilers inflate GPU timings. Measure with the existing gates (capped/uncapped, p50/p99 CPU+GPU, pinned dense Arwic).

6. Wanted work — atmospheric rendering (user-stated)

Opt-in shader packs, modelled on Minecraft's Iris/OptiFine: the retail-faithful path stays the default and authoritative; enhanced rendering is a toggle, exposed through the plugin API. This framing is what resolves the parity tension — the divergence register is untouched because the faithful path still exists and is still what we test.

Tier Contents Prerequisite
1 bloom, ACES filmic tonemap, colour grade, vignette none — fullscreen, scales with pixels
1 screen-space sun rays (crepuscular) sun screen-pos + occlusion mask; no shadow maps
2 cascaded directional shadows — trees, monsters, houses second scene pass
2+ volumetric light shafts reuses the shadow map — nearly free after shadows
later SSAO, water reflections depth + normals
out true PBR AC has no per-texture normal/roughness maps

Dynamic shadows are an explicit user want — trees, monsters and buildings casting real sun shadows — not merely a design exercise.

Design points the user asked for:

  • Ray/shaft intensity driven by AC's own authored weather (activeDayGroup) rather than invented constants — strong shafts at clear dawn, muted under Overcast. Combined with the already-authored moving sun this yields rays that rake low at dawn, vanish at noon, return at dusk. Retail never had this.
  • Composite sun rays before tonemapping so bloom picks them up and they roll off the filmic curve instead of clipping.

Shadow-specific constraints

  1. Alpha-tested casters. Foliage is cutout geometry (two-pass alpha), so the shadow pass cannot use portal_depth's empty fragment shader — it must sample and discard, or every tree casts a solid rectangle.
  2. Animated casters. Monsters need the same per-part transforms as the main pass; those already live in the N.5 SSBO, so the shadow vertex shader can read the same buffer.
  3. Indoors has no sun. Gate to outdoor cells; dungeon cells use authored per-cell ambient and must not be fought.
  4. Cascades must be camera-relative, bounded by the two-tier streaming window (memory: reference_two_tier_streaming), not a fixed world extent.
  5. Depth bias will hit issue #129's bug class — an NDC-space bias constant spans ~ b*d^2/near metres of eye depth at distance; #129 leaked door-shaped holes through hills. Memory: feedback_ndc_constants_eye_space_meaning.
  6. NAMING TRAP: in AC's codebase "shadow" means the per-cell physics registration list (CPhysicsObj::add_shadows_to_cells, shadow_objects) — nothing to do with lighting. Grepping "shadow" drowns in collision hits.
  7. Quality scaling for weak hardware: half/quarter-res bloom and rays, fewer cascades, lower shadow resolution. 4K pays ~4x the tier-1 pixel cost.

7. Open questions carried forward

  1. Does retail smooth terrain vertex normals? (§4) — decides parity-gap vs enhancement.
  2. Who calls SmartBox::SetDetailTexturing, and is landscape detail ever enabled in practice? (§2)
  3. What is the non-single-pass detail fallback in landPolyDraw? (§2)
  4. What uses the BumpMap / DotProduct3 path, if not the detail textures? (§3)
  5. For #226: port retail's brightening blend verbatim, fix it, or expose both? ANSWERED 2026-08-22: neither — the brightening blend was the fallback; VM1 ported the single-pass lerp that real hardware runs (§2).

8. Where this sits

M4 is the active milestone; the next planned implementation work is the #268 + TS-8 stat-chain package, and none of the above should displace it without the user's say-so. #226 and the terrain-normals question are parity gaps (schedulable now). The shader-pack tiers are post-M7, since rendering phases are frozen until the polish pass — but they are wanted, so the design should be captured rather than rediscovered.