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

349 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
**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):
```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** |
**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`](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::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**. **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.