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>
349 lines
17 KiB
Markdown
349 lines
17 KiB
Markdown
# 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: 11–14 % of pixels at mean |Δ| ≈ 1–1.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.
|