acdream/docs/research/2026-08-21-terrain-and-atmospheric-rendering-findings.md
Erik 5ca029d3ea docs(research): terrain detail/normals + atmospheric rendering findings
Measured, not theorised. Records for the next session:

- #226 detail overlay is NOT implemented at 255b0aae, and the
  BuildingDetailTextures Options checkbox is wired to NOTHING (settings +
  UI exist; no consumer in Rendering/). The port must consume that
  existing setting.
- Retail authors detail per terrain entry; Dereth has 33 entries but only
  3 distinct detail textures, one 64x64 backing 88% of them.
- The detail pass is a framebuffer blend, not DOT3: environment uses
  DSTCOLOR+INVSRCALPHA under BLENDOP_ADD, landscape uses SRCALPHA. Decoded
  texture means give factors 1.03-1.20, so retail's own pass BRIGHTENS
  rather than roughens - a real design decision for the port.
- EnvironmentDetailTextures gates buildings+interiors, NOT landscape
  (ChangeRegion passes landscape=0).
- Refuted and recorded so nobody re-chases: highres is NOT an override dat
  (20,684/2,294 ids, zero overlap - we already use it); the detail
  textures are NOT normal maps (24%/8%/15% unit-length); but the engine
  DOES have BumpMap/DotProduct3 machinery.
- Terrain normals are flat per-face with no averaging - whether retail
  smooths is the open parity question.
- Atmospheric rendering want captured: opt-in shader packs, tier table,
  and the shadow constraints (alpha-tested casters, animated casters via
  the N.5 SSBO, indoor gating, streaming-bounded cascades, the #129 bias
  trap, and the shadow_objects naming trap).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:02:27 +02:00

15 KiB

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 — its callers are untraced; that is the open question for whether terrain detail is ever enabled in practice.

The blend — and retail's own bug

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

Retail's own detail pass BRIGHTENS rather than roughens. A proper roughening modulate needs dst = ZERO so the result centres on 1.0; here the dest x (1-alpha) term adds most of the original back on top. This independently confirms the community observation that it looks like surfaces "reflect more instead of being rougher", and it raises a real design decision for #226 (see open questions).

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 (cross product of whichever triangle the sample lands in). No averaging anywhere in the tree. That is what produces the faceted look.

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).

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 to dst=ZERO so it roughens as intended and register the deviation, or expose both behind the setting? (§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.