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>
This commit is contained in:
Erik 2026-08-21 15:02:27 +02:00
parent 255b0aaeda
commit 5ca029d3ea

View file

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