Skip to content

Commit 1280e87

Browse files
gajopclaude
andcommitted
Add textures feature in Rust with registry-driven integration tests
Port texture painting to the feature-first layout (sbc/textures/{commands,model,tests}): a TextureModel container of publicly-accessible components (tiles, shading, cache, history), per-mode draw passes, and a Texture newtype used throughout, with strings converted only at the JSON boundary. Integration tests follow one-command-per-test: each asserts the real pixel effect of a single command (dirty + actual colour/alpha), with transparent no-op cases as their own tests. No copy-pasted shader/pipeline code. Smoke harness is now registry-driven: tests self-register via integration_test! (tagged by module path); a single tools/smoke/test_integration.py boots once and runs every registered test, with SBC_TEST_TAGS to filter a slice. Removes the per-slice pytest files and all Python-side test-name/artifact enumeration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 571bad3 commit 1280e87

59 files changed

Lines changed: 4524 additions & 177 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/design/texture-paint.md

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
---
2+
name: Texture paint
3+
description: How the editor paints map diffuse and shading textures
4+
---
5+
6+
# Texture paint
7+
8+
Texture painting edits the visual material of the map. A brush stroke can write
9+
to the diffuse ground texture, one or more shading textures, or the DNTS splat
10+
distribution texture. All writes happen through editable FBO textures that the
11+
engine is then told to render from.
12+
13+
## Concepts
14+
15+
The engine owns the original map textures. The editor owns editable mirrors.
16+
Paint operations render into those mirrors, not directly into the engine-owned
17+
textures.
18+
19+
```mermaid
20+
flowchart LR
21+
EngineDiffuse["Engine diffuse square"] --> DiffuseCopy["Editable diffuse FBO tile"]
22+
DiffuseCopy --> EngineDiffuseBinding["SetMapSquareTexture"]
23+
24+
EngineShading["Engine shading texture"] --> ShadingCopy["Editable shading FBO"]
25+
ShadingCopy --> EngineShadingBinding["SetMapShadingTexture"]
26+
27+
Brush["Brush stroke"] --> PaintPass["Shader paint pass"]
28+
Pattern["Pattern texture"] --> PaintPass
29+
PaintPass --> DiffuseCopy
30+
PaintPass --> ShadingCopy
31+
```
32+
33+
The texture manager keeps three groups of state:
34+
35+
- Diffuse tiles: 1024 by 1024 FBO textures covering the map.
36+
- Shading textures: optional editable FBOs for `$ssmf_specular`,
37+
`$ssmf_emission`, `$ssmf_sky_refl`, `$ssmf_splat_distr`,
38+
`$ssmf_splat_normals:0..3`, and `$detail`.
39+
- Undo copies: temporary FBO backups of textures touched by the current stroke.
40+
41+
## Initialization
42+
43+
When texture painting is first used, the manager creates editable copies and
44+
binds them back to the engine.
45+
46+
```mermaid
47+
sequenceDiagram
48+
participant TM as TextureModel
49+
participant VFS as Vfs
50+
participant GFX as Gfx
51+
participant Engine as Engine
52+
53+
TM->>VFS: get_map_square_texture(i, j, scratch)
54+
TM->>GFX: create FBO tile
55+
TM->>GFX: blit scratch to tile
56+
TM->>VFS: set_map_square_texture(i, j, tile)
57+
58+
TM->>GFX: texture_info("$ssmf_*")
59+
alt texture exists
60+
TM->>GFX: create shading FBO
61+
TM->>GFX: blit engine texture to shading FBO
62+
TM->>Engine: set_map_shading_texture(name, FBO, slot)
63+
else texture is absent
64+
TM-->>TM: leave slot disabled until explicitly created
65+
end
66+
```
67+
68+
Some shading textures are optional. A map may not ship all of them. When the
69+
editor enables a missing shading texture, that texture is created and bound
70+
through the engine first. Before painting an enabled channel, the texture
71+
manager checks whether it already has an editable mirror. Existing map textures
72+
are mirrored by engine lookup name. Newly created optional textures are mirrored
73+
from the texture handle carried by the paint command.
74+
75+
## Paint Flow
76+
77+
`TerrainChangeTextureCommand` is the command entry point. It normalizes the
78+
brush parameters, computes the affected region, and dispatches to the paint mode
79+
implementation.
80+
81+
```mermaid
82+
flowchart TD
83+
Command["TerrainChangeTextureCommand"] --> Region["Compute world region"]
84+
Region --> Mode{"paintMode"}
85+
86+
Mode -->|"paint"| Diffuse["paint_diffuse"]
87+
Mode -->|"paint"| Shading["paint_shading_textures"]
88+
Mode -->|"void"| Void["paint_void"]
89+
Mode -->|"blur"| Filter["paint_filter"]
90+
Mode -->|"height"| Height["paint_height"]
91+
Mode -->|"dnts"| DNTS["paint_dnts"]
92+
93+
Diffuse --> Tiles["Diffuse FBO tiles"]
94+
Void --> Tiles
95+
Filter --> Tiles
96+
Height --> Tiles
97+
Shading --> ShadingTargets["Enabled shading FBOs"]
98+
DNTS --> Splat["splat_distr FBO"]
99+
```
100+
101+
Every pass follows the same rendering pattern:
102+
103+
1. Compile or fetch the shader from `ShaderCache`.
104+
2. Bind pattern, brush, heightmap, or previous texture inputs.
105+
3. Open undo backups for the target textures.
106+
4. Render a quad into each target FBO with `Gfx::render_to_texture`.
107+
5. Mark touched targets dirty.
108+
109+
## Paint Modes
110+
111+
| Mode | Target | Shader | Purpose |
112+
|------|--------|--------|---------|
113+
| `paint` | Diffuse tiles and enabled shading textures | `map_drawing.glsl` | Apply brush and pattern with the selected blend mode. |
114+
| `void` | Diffuse tiles | `void_drawing.glsl` | Reduce alpha in the diffuse texture. |
115+
| `blur` | Diffuse tiles | `map_blur_drawing.glsl` | Apply a filter kernel such as blur, outline, or Sobel. |
116+
| `height` | Diffuse tiles | `map_height_drawing.glsl` | Paint using heightmap-weighted color. |
117+
| `dnts` | `$ssmf_splat_distr` | `dnts_drawing.glsl` | Write the selected DNTS material slot. |
118+
119+
Shader programs are cached by shader path and blend mode. Sampler uniforms use
120+
fixed texture units so all passes can share the same binding conventions.
121+
122+
## Region Math
123+
124+
The brush is a square in world space. Rotation can make its bounding box larger
125+
than the original square, so the command computes an axis-aligned world-space
126+
region before deciding which targets are touched.
127+
128+
```mermaid
129+
flowchart LR
130+
Brush["Brush center, size, rotation"] --> Corners["Rotated corners"]
131+
Corners --> Bounds["Axis-aligned bounds"]
132+
Bounds --> WorldRegion["World-space region"]
133+
WorldRegion --> TileRegion["Tile-space region for diffuse"]
134+
WorldRegion --> ShadingRegion["Normalized map region for shading"]
135+
```
136+
137+
Diffuse passes divide by `TEXTURE_SIZE` to find affected FBO tiles. Shading
138+
passes use normalized whole-map coordinates because each shading texture covers
139+
the map as one texture.
140+
141+
## Undo
142+
143+
Texture painting uses copy-on-write undo. The first time a stroke touches a
144+
texture, the manager copies the current texture into a backup FBO. Later writes
145+
in the same stroke reuse that backup.
146+
147+
```mermaid
148+
stateDiagram-v2
149+
[*] --> Idle
150+
Idle --> Painting: first target touched
151+
Painting --> Painting: more targets touched
152+
Painting --> Closed: merged stroke command
153+
Closed --> Restored: pop_stack
154+
Restored --> Idle
155+
156+
Painting: active_backups contains one FBO copy per touched target
157+
Closed: undo_stack contains a closed backup group
158+
Restored: backups are blitted back and deleted
159+
```
160+
161+
Tile backups and shading backups live in the same undo group. A stroke that
162+
touches both diffuse and shading textures therefore reverts atomically.
163+
164+
Closed texture undo groups are keyed by command id. The command manager emits
165+
history events when ids leave undo/redo history, and the texture manager drops
166+
matching GPU backups.
167+
168+
```mermaid
169+
sequenceDiagram
170+
participant CM as CommandManager
171+
participant TM as TextureModel
172+
173+
CM->>TM: execute paint samples
174+
CM->>TM: execute merged stroke command with command id
175+
TM->>TM: close backup group under command id
176+
CM-->>TM: UndoEvicted(command id)
177+
TM->>TM: delete backups for command id
178+
CM-->>TM: RedoCleared(command ids)
179+
TM->>TM: delete backups for command ids
180+
CM-->>TM: Cleared
181+
TM->>TM: delete all active, undo, and redo backups
182+
```
183+
184+
## Dirty State
185+
186+
Dirty flags record which editable textures have changed. Tests use them for
187+
paint/undo assertions, and save/export code can use them to decide which
188+
textures need to be written.
189+
190+
```mermaid
191+
flowchart TD
192+
Paint["Paint pass writes FBO"] --> Dirty["Set dirty flag"]
193+
Undo["Undo restore"] --> RestoreDirty["Restore previous dirty flag"]
194+
```
195+
196+
Undo restores both pixels and the previous dirty flag. That keeps the texture
197+
manager's state consistent even before project save/export uses these flags.
198+
199+
## Runtime Requirements
200+
201+
Texture painting must run where GL and unsynced rendering APIs are valid.
202+
`Gfx::render_to_texture` binds the target FBO for the duration of its callback,
203+
so all draw calls and readbacks for that target must happen inside the callback.
204+
205+
The same rule applies to tests that call `read_pixels` or `save_image`: bind the
206+
FBO with `render_to_texture`, then read or save while it is still bound.
207+
208+
## Tests
209+
210+
The texture tests cover both GL primitives and paint behavior.
211+
212+
```mermaid
213+
flowchart TD
214+
GfxTests["GL primitive tests"] --> RenderReadback["render_to_texture + read_pixels"]
215+
GfxTests --> Blit["FBO blit"]
216+
GfxTests --> ShaderPass["Custom shader pass"]
217+
218+
PaintTests["Paint tests"] --> DirtyBits["Dirty-bit assertions"]
219+
PaintTests --> PngDiff["Before and after PNG byte diff"]
220+
PaintTests --> UndoRoundTrip["Undo restores baseline bytes"]
221+
PaintTests --> Infolog["No warnings, errors, or crashes"]
222+
```
223+
224+
PNG byte comparison is used as a practical visual assertion. With deterministic
225+
image output, matching bytes mean matching pixels, and differing bytes prove the
226+
paint pass changed the target texture.
227+
228+
## Related Files
229+
230+
The textures feature lives under `native/src/sbc/textures/{commands,model,tests}`.
231+
232+
- `textures/model/texture_model/``TextureModel` (a container of components) +
233+
`tiles.rs`, `shading.rs`, `cache.rs`, `history.rs` (active stroke + undo/redo),
234+
`surface.rs`
235+
- `textures/model/draw/` — the per-mode paint passes
236+
- `textures/model/shader_cache.rs`, `textures/model/texture_drawing.rs`,
237+
`textures/model/graphics.rs`
238+
- `textures/commands/``TerrainChangeTextureCommand`, its merged command,
239+
`CacheTextureCommand`
240+
- `textures/tests/` — in-engine tests, each registered via `crate::integration_test!`
241+
- `tools/smoke/test_integration.py` — boots once and runs every registered test
242+
(filter a slice with `SBC_TEST_TAGS=textures`)

0 commit comments

Comments
 (0)