From 5ca029d3ead79300ea18d8ed76f8aed17783f320 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 15:02:27 +0200 Subject: [PATCH] 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 --- ...rain-and-atmospheric-rendering-findings.md | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 docs/research/2026-08-21-terrain-and-atmospheric-rendering-findings.md diff --git a/docs/research/2026-08-21-terrain-and-atmospheric-rendering-findings.md b/docs/research/2026-08-21-terrain-and-atmospheric-rendering-findings.md new file mode 100644 index 00000000..6add539b --- /dev/null +++ b/docs/research/2026-08-21-terrain-and-atmospheric-rendering-findings.md @@ -0,0 +1,322 @@ +# 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: + +```c +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): + +```c +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::landPolyDraw` — **two 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.