mirror of
https://github.com/MaSzyna-EU07/maszyna.git
synced 2026-11-03 23:32:06 +01:00
Cook the ground into a quadtree of mesh tiles, and keep the mesh the scenery drew
The ground in a scenery is already an irregular mesh, so the cook keeps it: it cuts the triangles along the tile grid, welds what coincides, and builds each coarser level by collapsing edges. Nothing is resampled, nothing is interpolated, and a hole the scenery left stays a hole. What was here before threw the triangles away, kept their vertices and built a fresh Delaunay triangulation over them, which filled every hole, merged the two heights of every retaining wall into one, and - because it never erased the super-triangle - emitted indices three too large together with triangles reaching out to the artificial corners. Tiles form a quadtree, as Cesium Quantized Mesh intends. A distant tile is a large one, so the horizon costs tens of tiles rather than the thousands a fixed grid needed, and the level to draw follows from the tile's own geometric error against what the screen can resolve - which is what the format carries that field for. Every vertex on a tile's border survives into all of its coarser levels, and so does every edge between two of them, so two neighbours drawn at different levels share their border exactly: there is nothing to stitch and nothing to morph. The file layout is the format's, with two deliberate departures, both written down in quantizedmeshformat.h: the geodetic header fields carry scenery metres, there being no geographic reference to convert from, and the material a vertex belongs to rides in an extension of ours. Texture coordinates are not stored at all - ground tiles in world space, so the coordinate follows from the position and from how many metres one repeat of the material covers, measured off the scenery once and kept in the terrain's table. Tiles live in one archive beside the scenery, an entry each, compressed on its own. Baltyk cooks to 546 kB against 2.2 MB of heightfields, quark to 361 kB against 9.6 MB, tomaszewo to 375 kB against 9.9 MB. Drawing is one call a frame: every resident tile lives in the same vertex and index buffer, with its vertices measured from the middle of the terrain so that the whole map fits in floats, and a frame is one glMultiDrawElementsBaseVertex over the tiles the walk chose and the frustum kept. On baltyk that settles at 291 tiles resident, 16 MB of mesh, 85 tiles and 6k triangles drawn. What counts as ground is what the scenery says, not what its triangles look like. A node under origin, rotate or scale is an object standing on the ground; a material with an alpha channel is a tree, a bush, a fence or a catenary mast. Everything else is laid whichever way it faces, so the side of an embankment or a cutting goes in with the rest instead of leaving a hole along every bank. Whatever is left out is recorded per node, and the scenery goes on drawing it. A cook is a cache: it is used again only when the archive, the note of which nodes went into it and the manifest of what the scenery was made from all agree. A manifest listing a file the scenery names but does not have used to fail that test for ever, since an absent file never matches - quark has such an include, and nothing beside it could ever be reused. tools/terraincook cooks the same way outside the engine, and meshtest beside it checks what the cook promises: a tile reads back as written, neighbouring tiles agree on their border to the millimetre, a hole is not filled in, a wall is laid exactly once, and a material's tiling comes out of the source. seamcheck.py goes: it tested the morphing constants of a heightfield nothing reads any more.
This commit is contained in:
@@ -192,11 +192,13 @@ set(SOURCES
|
|||||||
"rendering/terrainclipmap.cpp"
|
"rendering/terrainclipmap.cpp"
|
||||||
"rendering/terraintileloader.cpp"
|
"rendering/terraintileloader.cpp"
|
||||||
"rendering/terraintilepool.cpp"
|
"rendering/terraintilepool.cpp"
|
||||||
|
"rendering/terrainmesharena.cpp"
|
||||||
"rendering/terrainground.cpp"
|
"rendering/terrainground.cpp"
|
||||||
"simulation/loadprofile.cpp"
|
"simulation/loadprofile.cpp"
|
||||||
"simulation/cookdeps.cpp"
|
"simulation/cookdeps.cpp"
|
||||||
"simulation/logiccook.cpp"
|
"simulation/logiccook.cpp"
|
||||||
"simulation/terrainbake.cpp"
|
"simulation/terrainbake.cpp"
|
||||||
|
"scene/quantizedmeshcooker.cpp"
|
||||||
"simulation/simulationtime.cpp"
|
"simulation/simulationtime.cpp"
|
||||||
"scene/sceneeditor.cpp"
|
"scene/sceneeditor.cpp"
|
||||||
"rendering/screenshot.cpp"
|
"rendering/screenshot.cpp"
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
# TIN (Triangulated Irregular Network) Terrain Format
|
|
||||||
|
|
||||||
Implementation of Cesium Quantized Mesh format for adaptive terrain triangulation as an alternative to regular heightfield.
|
|
||||||
|
|
||||||
## What's Implemented
|
|
||||||
|
|
||||||
### 1. Format Definition (`scene/quantizedmeshformat.h`)
|
|
||||||
|
|
||||||
Cesium Quantized Mesh format adapted for MaSzyna:
|
|
||||||
- **Header**: 128 bytes with tile bounds, vertex/triangle counts, LOD error metrics
|
|
||||||
- **Vertex data**: Quantized uint16[3] positions (XYZ) - 6 bytes per vertex
|
|
||||||
- **UV data**: Quantized uint16[2] texture coords - 4 bytes per vertex
|
|
||||||
- **Indices**: uint16 or uint32 depending on vertex count
|
|
||||||
- **Edge indices**: For seamless LOD stitching (north/south/east/west)
|
|
||||||
- **Extensions**: Material splat, metadata, meshlets
|
|
||||||
|
|
||||||
**Compression**:
|
|
||||||
- Positions quantized to tile bounding box (2mm precision for 256m tiles)
|
|
||||||
- UVs quantized 0-1 range
|
|
||||||
- Optional per-tile zstd compression (same as heightfield)
|
|
||||||
|
|
||||||
### 2. Triangulator (`scene/terraincooker_tin.{h,cpp}`)
|
|
||||||
|
|
||||||
Constrained Delaunay triangulation using [CDT library](https://github.com/artem-ogre/CDT):
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Input: Same triangle soup as heightfield cooker (compatible interface)
|
|
||||||
- Constraint edges: Track corridors, roads, water boundaries
|
|
||||||
- Multi-LOD generation: 4 levels by default, configurable
|
|
||||||
- Edge-collapse simplification per LOD
|
|
||||||
- Automatic edge extraction for seamless tile stitching
|
|
||||||
|
|
||||||
**LOD Strategy**:
|
|
||||||
- Level 0 (finest): Full triangulation with all constraints
|
|
||||||
- Level 1-3: Progressive simplification, removing low-error vertices
|
|
||||||
- Geometric error tracked per LOD (for distance-based selection)
|
|
||||||
- Target: 30% vertex reduction per LOD level
|
|
||||||
|
|
||||||
**Statistics** (from `binary-scene-format-plan.md` measurements):
|
|
||||||
```
|
|
||||||
l204 terrain: 8.8M triangles
|
|
||||||
Expected TIN output: ~4.4M triangles (50% reduction)
|
|
||||||
Heightfield cost: 186 MB (2m grid)
|
|
||||||
TIN cost estimate: ~90-120 MB (better compression, variable density)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Integration (`tools/terraincook/main.cpp`)
|
|
||||||
|
|
||||||
New mode: `-cooktin` alongside existing `-cook`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
terraincook <terrain.scm files> -cooktin -tilesize 256 -lodlevels 4 -out cooked/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output**:
|
|
||||||
- `terrain_X_Z_lodN.qm` per tile per LOD
|
|
||||||
- Binary Quantized Mesh format
|
|
||||||
- Compatible with existing tile coordinate system
|
|
||||||
|
|
||||||
**Parameters**:
|
|
||||||
- `-tilesize <metres>`: Tile size (default: 256m)
|
|
||||||
- `-lodlevels <n>`: Number of LOD levels (default: 4)
|
|
||||||
- `-raw`: Disable compression (for debugging)
|
|
||||||
|
|
||||||
### 4. Key Design Decisions
|
|
||||||
|
|
||||||
**Why TIN over Heightfield?**
|
|
||||||
|
|
||||||
From measurements (`binary-scene-format-plan.md`):
|
|
||||||
- Track corridor (< 25m): 91.8% triangles have edges < 4m
|
|
||||||
- Open field (> 1600m): Only 25.7% need fine detail
|
|
||||||
- Uniform 2m grid wastes resolution where not needed
|
|
||||||
- TIN adapts: dense along infrastructure, coarse elsewhere
|
|
||||||
|
|
||||||
**Why Cesium Quantized Mesh?**
|
|
||||||
|
|
||||||
- Industry standard, proven at scale (Cesium terrain streaming)
|
|
||||||
- Efficient: quantization + index compression
|
|
||||||
- Edge constraints solve T-junction problem (no cracks between LODs)
|
|
||||||
- Extensible: material splat, normals, metadata
|
|
||||||
- Open format, not tied to any vendor
|
|
||||||
|
|
||||||
**CDT vs Triangle vs Delaunator**:
|
|
||||||
|
|
||||||
- **CDT** (chosen): MPL-2.0, header-only, C++, constraint edges
|
|
||||||
- **Triangle**: Unclear license, C, harder to integrate
|
|
||||||
- **Delaunator**: Fast but no constraints (can't preserve track edges)
|
|
||||||
|
|
||||||
## What's NOT Yet Implemented
|
|
||||||
|
|
||||||
### 1. Loader (`rendering/terraintileloader.cppm`)
|
|
||||||
|
|
||||||
Current loader reads heightfield `.ehf` tiles. Needs:
|
|
||||||
- Parallel reader for `.qm` format
|
|
||||||
- Dequantization of vertices
|
|
||||||
- Upload to GPU (vertex buffer instead of texture array)
|
|
||||||
|
|
||||||
### 2. Renderer (`rendering/terrainclipmap.cppm`)
|
|
||||||
|
|
||||||
Current clipmap renders from texture arrays. Needs:
|
|
||||||
- Vertex buffer pool (replace `terrain_tile_pool` texture arrays)
|
|
||||||
- Index buffer per LOD
|
|
||||||
- Vertex input state (position, UV, material index)
|
|
||||||
- Edge stitching shader (use edge indices to eliminate cracks)
|
|
||||||
|
|
||||||
### 3. Material Splat
|
|
||||||
|
|
||||||
TIN vertices carry material indices. Needs:
|
|
||||||
- Per-vertex material blending in fragment shader
|
|
||||||
- Material texture array (same as heightfield)
|
|
||||||
- Possibly: bake splat into vertex colors for simpler shader
|
|
||||||
|
|
||||||
### 4. Constraint Generation
|
|
||||||
|
|
||||||
Current implementation takes constraints as input. Needs:
|
|
||||||
- Automatic extraction from track geometry
|
|
||||||
- Road boundaries from scenery
|
|
||||||
- Water edges from material boundaries
|
|
||||||
|
|
||||||
## Build Setup
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
Add to CMake or vcpkg:
|
|
||||||
```
|
|
||||||
find_package(CDT CONFIG REQUIRED)
|
|
||||||
target_link_libraries(target PRIVATE CDT::CDT)
|
|
||||||
```
|
|
||||||
|
|
||||||
CDT is available in vcpkg:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "cdt",
|
|
||||||
"version": "1.4.4",
|
|
||||||
"description": "Constrained Delaunay Triangulation"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Files Added
|
|
||||||
|
|
||||||
```
|
|
||||||
scene/quantizedmeshformat.h - Format specification
|
|
||||||
scene/terraincooker_tin.h - Triangulator interface
|
|
||||||
scene/terraincooker_tin.cpp - Triangulator implementation
|
|
||||||
tools/terraincook/main.cpp - Integration (modified)
|
|
||||||
tools/terraincook/CMakeLists.txt - Build config (modified)
|
|
||||||
tools/terraincook/test_tin.cpp - Simple test program
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### 1. Cook Test Data
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build terraincook with CDT dependency
|
|
||||||
cd tools/terraincook && mkdir build && cd build
|
|
||||||
cmake .. -DCMAKE_BUILD_TYPE=Release
|
|
||||||
make
|
|
||||||
|
|
||||||
# Run on small scenery subset
|
|
||||||
./terraincook ../../test_data/teren -cooktin -out cooked_tin/
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected output:
|
|
||||||
```
|
|
||||||
Triangulating tiles: 100/100
|
|
||||||
Triangulation complete:
|
|
||||||
Input triangles: 8979
|
|
||||||
Output triangles: 4512 (49.7% reduction)
|
|
||||||
Output vertices: 2389
|
|
||||||
Wrote 400 quantized mesh files to cooked_tin/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Verify Tile Format
|
|
||||||
|
|
||||||
```bash
|
|
||||||
hexdump -C cooked_tin/terrain_0_0_lod0.qm | head -20
|
|
||||||
```
|
|
||||||
|
|
||||||
Should see:
|
|
||||||
```
|
|
||||||
00000000 45 55 30 37 51 4d 53 48 01 00 00 00 ... |EU07QMSH|
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Compare to Heightfield
|
|
||||||
|
|
||||||
```bash
|
|
||||||
du -sh cooked/terrain.ehf cooked_tin/*.qm
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: TIN ~30-40% smaller for l204 terrain.
|
|
||||||
|
|
||||||
## Next Steps (Priority Order)
|
|
||||||
|
|
||||||
1. **Verify triangulation quality**
|
|
||||||
- Export preview images (height, overlay, triangle density)
|
|
||||||
- Check edge constraints preserved
|
|
||||||
- Measure actual reduction vs. theory
|
|
||||||
|
|
||||||
2. **Implement loader**
|
|
||||||
- Read `.qm` files on background thread
|
|
||||||
- Dequantize and upload to GPU
|
|
||||||
- Integrate with existing `terrain_tile_loader` thread pool
|
|
||||||
|
|
||||||
3. **Adapt renderer**
|
|
||||||
- Replace texture fetch with vertex attributes
|
|
||||||
- Add edge stitching (sample coarser LOD along shared edges)
|
|
||||||
- Preserve existing CDLOD distance logic
|
|
||||||
|
|
||||||
4. **Constraint extraction**
|
|
||||||
- Parse track geometry from `.scm`
|
|
||||||
- Generate corridor outline (track width + margin)
|
|
||||||
- Feed to triangulator as constraints
|
|
||||||
|
|
||||||
5. **Performance validation**
|
|
||||||
- Measure vs. heightfield: memory, load time, frame time
|
|
||||||
- Confirm 50% vertex reduction translates to visible win
|
|
||||||
- Check no regressions in visual quality
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Cesium Quantized Mesh Spec](https://github.com/CesiumGS/quantized-mesh)
|
|
||||||
- [CDT Library](https://github.com/artem-ogre/CDT)
|
|
||||||
- `binary-scene-format-plan.md` - Original measurements and rationale
|
|
||||||
- `scene/heightfieldformat.h` - Current heightfield format for comparison
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- **Hybrid approach viable**: Keep heightfield for flat areas, TIN for corridors
|
|
||||||
- **Format extensible**: Meshlets for mesh shading, normals, etc.
|
|
||||||
- **Deterministic**: CDT is deterministic for same input (reproducible builds)
|
|
||||||
- **Backward compat**: Old `.ehf` files still work, TIN is opt-in via `-cooktin`
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
# TIN Migration - Complete Implementation Guide
|
|
||||||
|
|
||||||
## Status: 95% Complete - Ready for Integration Testing
|
|
||||||
|
|
||||||
### ✅ Fully Implemented
|
|
||||||
|
|
||||||
#### 1. Format & Data Structures
|
|
||||||
- `scene/quantizedmeshformat.h` - Cesium Quantized Mesh spec ✅
|
|
||||||
- `scene/quantizedmeshreader.h` - Reader for `.qm` files ✅
|
|
||||||
- `scene/terraincooker_tin.{h,cpp}` - CDT triangulator + LOD ✅
|
|
||||||
|
|
||||||
#### 2. Tooling
|
|
||||||
- `tools/terraincook/main.cpp` - TIN is default format ✅
|
|
||||||
- `tools/terraincook/CMakeLists.txt` - CDT dependency ✅
|
|
||||||
|
|
||||||
#### 3. Loading Pipeline
|
|
||||||
- `rendering/terraintileloader.{cppm,cpp}` - Reads TIN tiles ✅
|
|
||||||
- `rendering/terraintilepool_tin.{cppm,cpp}` - VBO/IBO pool ✅
|
|
||||||
|
|
||||||
#### 4. Renderer Interface
|
|
||||||
- `rendering/terrainclipmap.cppm` - Updated for TIN ✅
|
|
||||||
- Key methods implemented:
|
|
||||||
- `open()` ✅
|
|
||||||
- `close()` ✅
|
|
||||||
- `upload()` ✅
|
|
||||||
- `level_for()` ✅
|
|
||||||
- `resolve_uniforms()` ✅
|
|
||||||
|
|
||||||
#### 5. Shaders
|
|
||||||
- `shaders/terrainclipmap_tin.vert` - Vertex attributes ✅
|
|
||||||
- `shaders/terrainclipmap_tin.frag` - Material lookup ✅
|
|
||||||
|
|
||||||
### 🔧 Remaining Integration Work
|
|
||||||
|
|
||||||
#### Critical (for basic rendering):
|
|
||||||
|
|
||||||
**1. Complete `terrainclipmap.cpp`**
|
|
||||||
|
|
||||||
Currently has partial TIN implementation. Need to:
|
|
||||||
|
|
||||||
a) Replace `render()` method (lines 360-468) with version from `TIN_RENDER_SNIPPET.cpp`
|
|
||||||
|
|
||||||
b) Fix `retire()` method:
|
|
||||||
```cpp
|
|
||||||
void terrain_clipmap::retire( resident_tile const &Tile ) {
|
|
||||||
m_pool.free( Tile.gpu_data ); // OLD: pool->release(Tile.slot)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
c) Update `scan()` method - mostly unchanged but remove heightfield references:
|
|
||||||
```cpp
|
|
||||||
// Change: m_reader.contains(x, z)
|
|
||||||
// To: m_reader.contains(x, z, wantedlevel)
|
|
||||||
```
|
|
||||||
|
|
||||||
d) Update remaining method signatures - search for `m_reader.` calls and adapt to new reader interface
|
|
||||||
|
|
||||||
**2. Update Shader Loading**
|
|
||||||
|
|
||||||
In `open()` (already done but verify):
|
|
||||||
```cpp
|
|
||||||
gl::shader vertex( "terrainclipmap_tin.vert" ); // Was: terrainclipmap.vert
|
|
||||||
gl::shader fragment( "terrainclipmap_tin.frag" ); // Was: terrainclipmap.frag
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Material Table Loading**
|
|
||||||
|
|
||||||
Currently placeholder in `open()`:
|
|
||||||
```cpp
|
|
||||||
// TODO: Load material names from first tile's extension
|
|
||||||
std::vector<std::string> materials = { "grass", "gravel", "dirt" };
|
|
||||||
```
|
|
||||||
|
|
||||||
Should read from `.qm` file extensions or separate material manifest.
|
|
||||||
|
|
||||||
#### Nice-to-Have (for production quality):
|
|
||||||
|
|
||||||
**4. Edge Stitching**
|
|
||||||
|
|
||||||
Use edge indices from loader payload to eliminate cracks between LOD levels.
|
|
||||||
|
|
||||||
In `render()`, before drawing tile at LOD N where neighbor is at LOD N+1:
|
|
||||||
```cpp
|
|
||||||
// Modify vertices on shared edge to match coarser neighbor
|
|
||||||
for( auto edge_idx : tile.north_edge ) {
|
|
||||||
// Fetch from neighbor's south_edge
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**5. Proper Material Blending**
|
|
||||||
|
|
||||||
Current frag shader does single material lookup. For smooth transitions:
|
|
||||||
```glsl
|
|
||||||
// Read 4 material weights from vertex
|
|
||||||
in vec4 vMaterialWeights;
|
|
||||||
in uvec4 vMaterialIndices;
|
|
||||||
|
|
||||||
vec4 color =
|
|
||||||
vMaterialWeights.x * texture(ground, vec3(vUV, vMaterialIndices.x)) +
|
|
||||||
vMaterialWeights.y * texture(ground, vec3(vUV, vMaterialIndices.y)) +
|
|
||||||
// ... etc
|
|
||||||
```
|
|
||||||
|
|
||||||
**6. Tile Size Auto-Detection**
|
|
||||||
|
|
||||||
Currently hardcoded:
|
|
||||||
```cpp
|
|
||||||
m_tilesize = 256.0f;
|
|
||||||
m_lodlevels = 4;
|
|
||||||
```
|
|
||||||
|
|
||||||
Should scan directory and parse from first tile header.
|
|
||||||
|
|
||||||
**7. Memory Budget Enforcement**
|
|
||||||
|
|
||||||
Track allocated vertices/indices and enforce limit:
|
|
||||||
```cpp
|
|
||||||
while( m_pool.allocated_bytes() > budget && !oldest_tiles.empty() ) {
|
|
||||||
retire( oldest_tiles.back() );
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 📝 Integration Steps
|
|
||||||
|
|
||||||
#### Phase 1: Manual File Edits (30 min)
|
|
||||||
|
|
||||||
1. Open `rendering/terrainclipmap.cpp`
|
|
||||||
2. Find `render()` method (line ~360)
|
|
||||||
3. Replace entire method with content from `TIN_RENDER_SNIPPET.cpp`
|
|
||||||
4. Search for `m_reader.` calls, update for quantizedmesh::reader API:
|
|
||||||
- `m_reader.tilesize()` → `m_tilesize`
|
|
||||||
- `m_reader.levels()` → `m_lodlevels`
|
|
||||||
- `m_reader.tiles()` → scan directory
|
|
||||||
5. Remove references to:
|
|
||||||
- `m_pools` (old texture pools)
|
|
||||||
- `m_indices` (old index buffers)
|
|
||||||
- `m_instances` (old instance buffer)
|
|
||||||
- `m_vao` (VAO now per-tile in pool)
|
|
||||||
|
|
||||||
#### Phase 2: Build Test (1 hour)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Install CDT if needed
|
|
||||||
vcpkg install cdt
|
|
||||||
|
|
||||||
# 2. Configure
|
|
||||||
cmake -B build -S . -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
|
||||||
|
|
||||||
# 3. Build (expect some linker errors - fix iteratively)
|
|
||||||
cmake --build build --target maszyna -j8
|
|
||||||
|
|
||||||
# Common fixes needed:
|
|
||||||
# - Add terraintilepool_tin.cpp to CMakeLists.txt
|
|
||||||
# - Add terrainclipmap_tin shaders to shader list
|
|
||||||
# - Remove old heightfield includes if any remain
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Phase 3: Cook Test Data (30 min)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build cooker
|
|
||||||
cmake --build build --target terraincook
|
|
||||||
|
|
||||||
# Cook small test area
|
|
||||||
./build/tools/terraincook/terraincook \
|
|
||||||
test_data/teren/*.scm \
|
|
||||||
-cook \
|
|
||||||
-tilesize 256 \
|
|
||||||
-lodlevels 3 \
|
|
||||||
-out cooked_tin/
|
|
||||||
|
|
||||||
# Verify output
|
|
||||||
ls -lh cooked_tin/
|
|
||||||
# Should see: terrain_0_0_lod0.qm, terrain_0_0_lod1.qm, etc.
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Phase 4: Runtime Test (1-2 hours)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run with test scenery
|
|
||||||
./build/maszyna -scenery cooked_tin/
|
|
||||||
|
|
||||||
# Debug checklist:
|
|
||||||
# 1. Does terrain load? (check logs for "Terrain TIN: ...")
|
|
||||||
# 2. Do tiles upload? (check GPU memory)
|
|
||||||
# 3. Does anything render? (might be black first time)
|
|
||||||
# 4. Are positions correct? (tiles should align with track)
|
|
||||||
# 5. Do LODs switch? (fly high, check triangle count drops)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🐛 Known Issues & Workarounds
|
|
||||||
|
|
||||||
**Issue 1: Black terrain**
|
|
||||||
- **Cause**: Material textures not loading
|
|
||||||
- **Fix**: Check `m_ground.create()` returns true, verify texture paths
|
|
||||||
|
|
||||||
**Issue 2: Terrain at wrong position**
|
|
||||||
- **Cause**: Camera-relative offset calculation wrong
|
|
||||||
- **Fix**: In render(), verify `tile_offset = origin - camera_pos`
|
|
||||||
|
|
||||||
**Issue 3: Cracks between tiles**
|
|
||||||
- **Cause**: Edge stitching not implemented
|
|
||||||
- **Workaround**: Use single LOD (set `-lodlevels 1` in cook)
|
|
||||||
- **Proper fix**: Implement Phase "Nice-to-Have" #4
|
|
||||||
|
|
||||||
**Issue 4: Performance worse than heightfield**
|
|
||||||
- **Cause**: Too many draw calls (one per tile)
|
|
||||||
- **Fix**: Batch tiles, or reduce tile count (larger tiles)
|
|
||||||
|
|
||||||
### 📊 Expected Results
|
|
||||||
|
|
||||||
**Memory (l204 scenery)**:
|
|
||||||
- Old: ~250 MB (heightfield texture arrays)
|
|
||||||
- New: ~150 MB (vertex/index buffers)
|
|
||||||
- Savings: 40%
|
|
||||||
|
|
||||||
**File Size**:
|
|
||||||
- Old: 186 MB (`.ehf`)
|
|
||||||
- New: ~100 MB (`.qm` files)
|
|
||||||
- Savings: 46%
|
|
||||||
|
|
||||||
**Triangle Count**:
|
|
||||||
- Old: 8.8M uniform
|
|
||||||
- New: 4.4M adaptive
|
|
||||||
- Savings: 50%
|
|
||||||
|
|
||||||
**Frame Time** (target: no worse than old):
|
|
||||||
- Old: instanced draw, ~0.5ms terrain
|
|
||||||
- New: per-tile draws, budget ~1.0ms
|
|
||||||
- If >2ms: need batching
|
|
||||||
|
|
||||||
### 🎯 Success Criteria
|
|
||||||
|
|
||||||
- [ ] Builds without errors
|
|
||||||
- [ ] Loads `.qm` tiles from directory
|
|
||||||
- [ ] Terrain renders at correct world position
|
|
||||||
- [ ] Multiple LODs visible (verify triangle count changes with distance)
|
|
||||||
- [ ] Materials display (grass/gravel textures visible)
|
|
||||||
- [ ] No crashes when flying across scenery
|
|
||||||
- [ ] Frame time < 2ms for terrain pass
|
|
||||||
- [ ] Memory usage < 200 MB for full l204
|
|
||||||
|
|
||||||
### 🔄 Rollback Plan
|
|
||||||
|
|
||||||
If integration fails:
|
|
||||||
|
|
||||||
1. Revert `rendering/terrainclipmap.{cppm,cpp}`
|
|
||||||
2. Revert `rendering/terraintileloader.{cppm,cpp}`
|
|
||||||
3. Keep all other files (they don't break anything)
|
|
||||||
4. Old heightfield pipeline continues working
|
|
||||||
5. Fix issues in separate branch before trying again
|
|
||||||
|
|
||||||
### 📚 Reference Files
|
|
||||||
|
|
||||||
- **TIN_RENDER_SNIPPET.cpp** - Complete render() implementation
|
|
||||||
- **TIN_IMPLEMENTATION.md** - Full design rationale
|
|
||||||
- **TIN_STATUS.md** - What's done, what remains
|
|
||||||
- **TIN_SESSION_SUMMARY.md** - Development log
|
|
||||||
- **TIN_RENDERER_PLAN.md** - Detailed renderer migration plan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Current State**: Code is 95% complete. Remaining work is integration (file edits, build fixes, testing).
|
|
||||||
|
|
||||||
**Time to Working**: 2-4 hours if following steps above.
|
|
||||||
|
|
||||||
**Recommendation**: Start with Phase 1 (manual file edits), then build incrementally fixing errors as they appear.
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
# TIN Renderer Migration - Full Replacement
|
|
||||||
|
|
||||||
## Status: Loader Complete, Renderer Next
|
|
||||||
|
|
||||||
### ✅ Complete
|
|
||||||
|
|
||||||
1. **Format** (`quantizedmeshformat.h`) - Cesium Quantized Mesh spec
|
|
||||||
2. **Triangulator** (`terraincooker_tin.{h,cpp}`) - CDT + LOD generation
|
|
||||||
3. **Cooker** (`tools/terraincook/main.cpp`) - `-cook` now generates TIN by default
|
|
||||||
4. **Loader** (`terraintileloader.{cppm,cpp}`) - Reads `.qm` files, dequantizes vertices
|
|
||||||
|
|
||||||
### 🚧 Next: Renderer (`rendering/terrainclipmap.{cppm,cpp}`)
|
|
||||||
|
|
||||||
#### Current State (Heightfield)
|
|
||||||
```cpp
|
|
||||||
// Texture arrays per LOD
|
|
||||||
terrain_tile_pool (GL_TEXTURE_2D_ARRAY)
|
|
||||||
- heights: R16
|
|
||||||
- materials: R8
|
|
||||||
|
|
||||||
// Shader samples texture
|
|
||||||
layout(binding = 0) uniform sampler2DArray heights;
|
|
||||||
vec4 h = texelFetch(heights, ivec3(x, y, layer));
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Target State (TIN)
|
|
||||||
```cpp
|
|
||||||
// Vertex buffer per tile
|
|
||||||
struct tile_vertex {
|
|
||||||
glm::vec3 position; // 12 bytes
|
|
||||||
glm::vec2 uv; // 8 bytes
|
|
||||||
std::uint8_t material;// 1 byte
|
|
||||||
std::uint8_t pad[3]; // alignment
|
|
||||||
}; // 24 bytes per vertex
|
|
||||||
|
|
||||||
// Index buffer per tile
|
|
||||||
std::vector<std::uint32_t> indices;
|
|
||||||
|
|
||||||
// Shader gets attributes
|
|
||||||
layout(location = 0) in vec3 position;
|
|
||||||
layout(location = 1) in vec2 uv;
|
|
||||||
layout(location = 2) in uint material_idx;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Implementation Plan
|
|
||||||
|
|
||||||
#### Phase 1: Replace `terrain_tile_pool`
|
|
||||||
|
|
||||||
**Current** (`rendering/terraintilepool.cppm`):
|
|
||||||
- Manages texture array layers
|
|
||||||
- Upload = `glTexSubImage3D` to layer
|
|
||||||
|
|
||||||
**New** (`rendering/terraintilepool_tin.cppm`):
|
|
||||||
```cpp
|
|
||||||
class terrain_tile_pool {
|
|
||||||
public:
|
|
||||||
struct tile_handle {
|
|
||||||
gl::buffer vbo; // vertex buffer
|
|
||||||
gl::buffer ibo; // index buffer
|
|
||||||
std::uint32_t vertex_count;
|
|
||||||
std::uint32_t index_count;
|
|
||||||
};
|
|
||||||
|
|
||||||
tile_handle upload(
|
|
||||||
std::vector<double> const &positions_x,
|
|
||||||
std::vector<double> const &positions_y,
|
|
||||||
std::vector<double> const &positions_z,
|
|
||||||
std::vector<float> const &uvs_u,
|
|
||||||
std::vector<float> const &uvs_v,
|
|
||||||
std::vector<std::uint8_t> const &materials,
|
|
||||||
std::vector<std::uint32_t> const &indices,
|
|
||||||
glm::dvec3 const &origin // for relative positioning
|
|
||||||
);
|
|
||||||
|
|
||||||
void free( tile_handle const &Handle );
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Phase 2: Adapt `terrain_clipmap::render()`
|
|
||||||
|
|
||||||
**Current flow**:
|
|
||||||
1. Frustum cull tiles → instance list
|
|
||||||
2. One instanced draw per LOD
|
|
||||||
3. Shader computes vertex from `gl_VertexID`
|
|
||||||
4. Shader samples heightmap texture
|
|
||||||
|
|
||||||
**New flow**:
|
|
||||||
1. Frustum cull tiles → draw list
|
|
||||||
2. For each tile:
|
|
||||||
- Bind its VBO/IBO
|
|
||||||
- `glDrawElementsBaseInstance` with tile's transform
|
|
||||||
3. Shader receives vertex attributes directly
|
|
||||||
|
|
||||||
**Key change**: From massive instanced draw to per-tile indexed draws.
|
|
||||||
|
|
||||||
#### Phase 3: Update Shaders
|
|
||||||
|
|
||||||
**terrainclipmap.vert** (before):
|
|
||||||
```glsl
|
|
||||||
// No vertex input - compute from ID
|
|
||||||
layout(location = 0) out vec2 vUV;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
int x = gl_VertexID % sideLength;
|
|
||||||
int y = gl_VertexID / sideLength;
|
|
||||||
|
|
||||||
// Fetch height from texture
|
|
||||||
float h = texelFetch(heights, ivec3(x, y, layer), 0).r;
|
|
||||||
|
|
||||||
vec3 pos = vec3(x * gridStep, h, y * gridStep);
|
|
||||||
vUV = vec2(x, y) / float(sideLength);
|
|
||||||
|
|
||||||
gl_Position = projView * vec4(pos + instanceOffset, 1.0);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**terrainclipmap.vert** (after):
|
|
||||||
```glsl
|
|
||||||
// Vertex attributes
|
|
||||||
layout(location = 0) in vec3 aPosition;
|
|
||||||
layout(location = 1) in vec2 aUV;
|
|
||||||
layout(location = 2) in uint aMaterial;
|
|
||||||
|
|
||||||
layout(location = 0) out vec2 vUV;
|
|
||||||
layout(location = 1) flat out uint vMaterial;
|
|
||||||
|
|
||||||
uniform vec3 uTileOffset; // from instance data
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
vUV = aUV;
|
|
||||||
vMaterial = aMaterial;
|
|
||||||
|
|
||||||
// Position is already world-space, just offset to camera-relative
|
|
||||||
vec3 pos = aPosition + uTileOffset;
|
|
||||||
gl_Position = projView * vec4(pos, 1.0);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**terrainclipmap.frag** (minimal change):
|
|
||||||
```glsl
|
|
||||||
// Material lookup stays the same
|
|
||||||
layout(binding = 1) uniform sampler2DArray groundTextures;
|
|
||||||
|
|
||||||
layout(location = 0) in vec2 vUV;
|
|
||||||
layout(location = 1) flat in uint vMaterial;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
vec4 color = texture(groundTextures, vec3(vUV, vMaterial));
|
|
||||||
fragColor = color;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Critical Details
|
|
||||||
|
|
||||||
#### 1. **Camera-Relative Rendering**
|
|
||||||
|
|
||||||
Heightfield stored absolute positions in camera-relative space per frame.
|
|
||||||
TIN vertices are **world-absolute** - subtract camera pos in shader:
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
// In clipmap::render()
|
|
||||||
glm::dvec3 camera_pos = Viewpoint;
|
|
||||||
|
|
||||||
// Per tile:
|
|
||||||
glm::vec3 tile_offset = glm::vec3(
|
|
||||||
tile_center - camera_pos // double → float is safe at this range
|
|
||||||
);
|
|
||||||
shader.uniform("uTileOffset", tile_offset);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. **LOD Morphing**
|
|
||||||
|
|
||||||
Heightfield morphed in shader between LOD levels.
|
|
||||||
TIN has **separate geometry per LOD** - just render appropriate one.
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
// Select LOD based on distance (existing logic)
|
|
||||||
std::uint32_t lod = level_for(distance);
|
|
||||||
|
|
||||||
// Fetch tile at that LOD
|
|
||||||
auto tile = m_tiles[make_key(x, z, lod)];
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. **Edge Stitching** (Phase 2)
|
|
||||||
|
|
||||||
Edge cracks between LOD levels need special handling:
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
// Along tile borders, sample neighbor's edge vertices
|
|
||||||
// Use edge indices from payload
|
|
||||||
|
|
||||||
if (on_north_edge && neighbor_lod > my_lod) {
|
|
||||||
// Fetch position from neighbor's south_edge
|
|
||||||
position = neighbor_south_edge[edge_index];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This can wait - without it, there might be thin cracks but rendering works.
|
|
||||||
|
|
||||||
### Files to Modify
|
|
||||||
|
|
||||||
```
|
|
||||||
rendering/terrainclipmap.cppm - Main renderer
|
|
||||||
rendering/terrainclipmap.cpp - Implementation
|
|
||||||
rendering/terraintilepool.cppm - Replace texture pool with buffer pool
|
|
||||||
rendering/terraintilepool.cpp - Implementation
|
|
||||||
shaders/terrainclipmap.vert - Vertex shader
|
|
||||||
shaders/terrainclipmap.frag - Fragment shader (minimal)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Files to Remove (Later)
|
|
||||||
|
|
||||||
```
|
|
||||||
scene/heightfieldformat.h - Old format
|
|
||||||
scene/heightfieldreader.h - Old reader
|
|
||||||
scene/terraincooker.h - Old cooker
|
|
||||||
rendering/terrainground.cppm - May be reusable for material textures
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing Strategy
|
|
||||||
|
|
||||||
1. **Render single tile**
|
|
||||||
- Load one `.qm` file
|
|
||||||
- Upload to VBO/IBO
|
|
||||||
- Draw with simple shader
|
|
||||||
- Verify vertices appear at correct world positions
|
|
||||||
|
|
||||||
2. **Render LOD 0 only**
|
|
||||||
- Load all tiles at finest LOD
|
|
||||||
- No morphing, no stitching
|
|
||||||
- Check coverage, no gaps
|
|
||||||
|
|
||||||
3. **Add LOD selection**
|
|
||||||
- Distance-based LOD (reuse existing formula)
|
|
||||||
- Verify coarser LODs appear at distance
|
|
||||||
|
|
||||||
4. **Add edge stitching**
|
|
||||||
- Use edge indices
|
|
||||||
- Eliminate cracks
|
|
||||||
|
|
||||||
5. **Performance comparison**
|
|
||||||
- Frame time vs old heightfield
|
|
||||||
- Memory usage
|
|
||||||
- Load time
|
|
||||||
|
|
||||||
### Next Session: Implement Phase 1
|
|
||||||
|
|
||||||
1. Create `terrain_tile_pool_tin.{cppm,cpp}`
|
|
||||||
2. Modify `terrain_clipmap::upload()` to use new pool
|
|
||||||
3. Modify `terrain_clipmap::render()` for per-tile draws
|
|
||||||
4. Update shaders
|
|
||||||
|
|
||||||
**Estimate**: 4-6 hours for working render, another 2-3 for edge stitching.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Ready to start renderer implementation now.**
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
# TIN Terrain Implementation - Session Summary
|
|
||||||
|
|
||||||
## ✅ Completed in This Session
|
|
||||||
|
|
||||||
### 1. Format & Triangulation (Complete)
|
|
||||||
- `scene/quantizedmeshformat.h` - Cesium Quantized Mesh format specification
|
|
||||||
- `scene/terraincooker_tin.{h,cpp}` - CDT-based triangulator with LOD generation
|
|
||||||
- `scene/quantizedmeshreader.h` - Reader for `.qm` tile files
|
|
||||||
|
|
||||||
### 2. Tool Integration (Complete)
|
|
||||||
- `tools/terraincook/main.cpp` - `-cook` now generates TIN by default
|
|
||||||
- Removed heightfield-specific flags
|
|
||||||
- TIN is the primary terrain format
|
|
||||||
|
|
||||||
### 3. Loader (Complete)
|
|
||||||
- `rendering/terraintileloader.{cppm,cpp}` - Fully converted to TIN
|
|
||||||
- Reads `.qm` files instead of `.ehf`
|
|
||||||
- Payload contains vertices, indices, UVs, materials
|
|
||||||
- Thread pool, priority queue all preserved
|
|
||||||
|
|
||||||
### 4. Renderer - In Progress
|
|
||||||
#### Completed:
|
|
||||||
- `rendering/terraintilepool_tin.{cppm,cpp}` - VBO/IBO pool for tile storage
|
|
||||||
- `rendering/terrainclipmap.cppm` - Interface updated for TIN rendering
|
|
||||||
- Changed imports (quantizedmesh, terraintilepool_tin)
|
|
||||||
- Updated resident_tile structure (VBO/IBO handles instead of texture slots)
|
|
||||||
- Removed heightfield-specific members (texture arrays, instance buffers)
|
|
||||||
- Added TIN-specific members (tile pool, simplified uniforms)
|
|
||||||
|
|
||||||
#### Remaining:
|
|
||||||
- `rendering/terrainclipmap.cpp` - Implementation needs rewrite:
|
|
||||||
- `open()` - Initialize from directory instead of single file
|
|
||||||
- `upload()` - Upload to VBO/IBO instead of texture
|
|
||||||
- `render()` - Per-tile indexed draws instead of instanced
|
|
||||||
- `scan()` - Minimal changes (LOD selection logic reusable)
|
|
||||||
- Remove `build_indices()` - not needed for TIN
|
|
||||||
|
|
||||||
### 5. Shaders - Not Started
|
|
||||||
- `shaders/terrainclipmap.vert` - Needs vertex attributes instead of texture fetch
|
|
||||||
- `shaders/terrainclipmap.frag` - Minimal changes (material lookup)
|
|
||||||
|
|
||||||
## 📊 Key Design Decisions Made
|
|
||||||
|
|
||||||
### 1. **Full Replacement, Not Hybrid**
|
|
||||||
Heightfield code remains in tree but TIN is the **only** format used going forward.
|
|
||||||
- Simpler: one code path
|
|
||||||
- Cleaner: no runtime format detection
|
|
||||||
- Faster development: no compatibility layer
|
|
||||||
|
|
||||||
### 2. **Vertex Buffer Per Tile**
|
|
||||||
```cpp
|
|
||||||
struct vertex {
|
|
||||||
float x, y, z; // 12 bytes
|
|
||||||
float u, v; // 8 bytes
|
|
||||||
uint8_t material; // 1 byte
|
|
||||||
uint8_t pad[3]; // alignment
|
|
||||||
}; // 24 bytes total
|
|
||||||
```
|
|
||||||
|
|
||||||
Each tile = one VBO + one IBO. No shared vertex pools or complex allocation.
|
|
||||||
|
|
||||||
### 3. **Camera-Relative Positions in VBO**
|
|
||||||
Vertices stored relative to tile origin, avoiding float precision issues:
|
|
||||||
```cpp
|
|
||||||
v.x = float(world_x - tile_center_x); // Safe: always < 256m
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **LOD Selection Reused**
|
|
||||||
Existing CDLOD distance formula preserved - only rendering path changes.
|
|
||||||
|
|
||||||
## 🔧 Technical Details
|
|
||||||
|
|
||||||
### Memory Layout
|
|
||||||
**Old (Heightfield)**:
|
|
||||||
```
|
|
||||||
Texture2DArray per LOD:
|
|
||||||
- 129×129 R16 heights = 33KB per tile
|
|
||||||
- 129×129 R8 materials = 17KB
|
|
||||||
Total per LOD level: 50KB × tiles
|
|
||||||
```
|
|
||||||
|
|
||||||
**New (TIN)**:
|
|
||||||
```
|
|
||||||
VBO + IBO per tile:
|
|
||||||
- ~2000 vertices × 24 bytes = 48KB
|
|
||||||
- ~6000 indices × 4 bytes = 24KB
|
|
||||||
Total: ~72KB per tile (but ~50% fewer triangles)
|
|
||||||
```
|
|
||||||
|
|
||||||
Net: **Similar memory**, but better quality where it matters.
|
|
||||||
|
|
||||||
### Rendering Path
|
|
||||||
**Old**:
|
|
||||||
```cpp
|
|
||||||
for each LOD level:
|
|
||||||
bind texture array
|
|
||||||
gather instances
|
|
||||||
glDrawElementsInstanced(all tiles at this LOD)
|
|
||||||
```
|
|
||||||
|
|
||||||
**New**:
|
|
||||||
```cpp
|
|
||||||
for each tile in view:
|
|
||||||
glBindVertexArray(tile.vao)
|
|
||||||
uniform(tile_offset, tile.origin - camera_pos)
|
|
||||||
glDrawElements(tile.index_count)
|
|
||||||
```
|
|
||||||
|
|
||||||
More draw calls, but modern GL handles this fine (< 1000 tiles/frame typical).
|
|
||||||
|
|
||||||
## 🚧 Remaining Work
|
|
||||||
|
|
||||||
### Critical Path (to working render):
|
|
||||||
1. **terrainclipmap.cpp** - Rewrite 5 key methods (~200 lines)
|
|
||||||
2. **Vertex shader** - Replace texture fetch with attributes (~30 lines)
|
|
||||||
3. **Fragment shader** - Trivial material lookup change (~5 lines)
|
|
||||||
|
|
||||||
### Nice-to-Have:
|
|
||||||
4. **Edge stitching** - Use edge indices to eliminate LOD cracks
|
|
||||||
5. **Constraint extraction** - Auto-generate from track geometry
|
|
||||||
6. **Material baking** - Proper per-vertex material assignment
|
|
||||||
|
|
||||||
## 📝 Next Session Plan
|
|
||||||
|
|
||||||
### Phase 1: Basic Rendering (2-3 hours)
|
|
||||||
1. Implement `terrain_clipmap::open()` - read directory, init pool
|
|
||||||
2. Implement `terrain_clipmap::upload()` - call pool.upload()
|
|
||||||
3. Implement `terrain_clipmap::render()` - per-tile draws
|
|
||||||
4. Write minimal shaders (attributes in, color out)
|
|
||||||
5. **Test**: single tile renders at correct position
|
|
||||||
|
|
||||||
### Phase 2: Full System (1-2 hours)
|
|
||||||
6. Implement `terrain_clipmap::scan()` - reuse LOD logic
|
|
||||||
7. Test multi-tile, LOD selection
|
|
||||||
8. Material textures working
|
|
||||||
|
|
||||||
### Phase 3: Polish (1-2 hours)
|
|
||||||
9. Performance tune (batch draws, cull aggressively)
|
|
||||||
10. Edge stitching (if cracks visible)
|
|
||||||
11. Memory budget enforcement
|
|
||||||
|
|
||||||
**Total estimate**: 4-7 hours to complete working TIN terrain.
|
|
||||||
|
|
||||||
## 🎯 Success Criteria
|
|
||||||
|
|
||||||
- [ ] Loads `.qm` tiles from directory
|
|
||||||
- [ ] Renders terrain at correct world positions
|
|
||||||
- [ ] LOD selection based on distance
|
|
||||||
- [ ] Materials display correctly
|
|
||||||
- [ ] No worse performance than heightfield
|
|
||||||
- [ ] Can fly entire l204 without hitches
|
|
||||||
|
|
||||||
## 📦 Files Modified (19 total)
|
|
||||||
|
|
||||||
**New files (8)**:
|
|
||||||
- `scene/quantizedmeshformat.h`
|
|
||||||
- `scene/quantizedmeshreader.h`
|
|
||||||
- `scene/terraincooker_tin.h`
|
|
||||||
- `scene/terraincooker_tin.cpp`
|
|
||||||
- `rendering/terraintilepool_tin.cppm`
|
|
||||||
- `rendering/terraintilepool_tin.cpp`
|
|
||||||
- `TIN_IMPLEMENTATION.md`
|
|
||||||
- `TIN_STATUS.md`
|
|
||||||
- `TIN_RENDERER_PLAN.md`
|
|
||||||
|
|
||||||
**Modified files (11)**:
|
|
||||||
- `tools/terraincook/main.cpp`
|
|
||||||
- `tools/terraincook/CMakeLists.txt`
|
|
||||||
- `rendering/terraintileloader.cppm`
|
|
||||||
- `rendering/terraintileloader.cpp`
|
|
||||||
- `rendering/terrainclipmap.cppm`
|
|
||||||
- `rendering/terrainclipmap.cpp` (in progress)
|
|
||||||
- `shaders/terrainclipmap.vert` (not started)
|
|
||||||
- `shaders/terrainclipmap.frag` (not started)
|
|
||||||
|
|
||||||
## 🐛 Known Issues
|
|
||||||
|
|
||||||
1. **terrainclipmap.cpp not yet updated** - Still has heightfield implementation
|
|
||||||
2. **Shaders not updated** - Still expect texture arrays
|
|
||||||
3. **No build tested** - CDT dependency needs vcpkg setup
|
|
||||||
4. **No visual verification** - Can't test until shaders done
|
|
||||||
|
|
||||||
## 💡 Key Insights
|
|
||||||
|
|
||||||
1. **TIN is not just "better heightfield"** - It's a fundamentally different data structure
|
|
||||||
- Heightfield: regular grid, texture-based
|
|
||||||
- TIN: irregular mesh, vertex-based
|
|
||||||
|
|
||||||
2. **The win comes from adaptivity** - Same visual quality with 50% fewer triangles because detail concentrates where needed
|
|
||||||
|
|
||||||
3. **Format is proven** - Cesium Quantized Mesh used in production at massive scale (globe-scale terrain streaming)
|
|
||||||
|
|
||||||
4. **Implementation is straightforward** - Once you commit to vertex buffers, the rest follows naturally
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Ready to continue with terrainclipmap.cpp implementation in next session.**
|
|
||||||
|
|
||||||
Current branch: `feature/scene-streaming-format`
|
|
||||||
All changes committed? **Not yet** - working files only.
|
|
||||||
162
TIN_STATUS.md
162
TIN_STATUS.md
@@ -1,162 +0,0 @@
|
|||||||
# TIN Terrain Format - Implementation Status
|
|
||||||
|
|
||||||
## ✅ Completed (Phase 1: Cooker)
|
|
||||||
|
|
||||||
### Core Infrastructure
|
|
||||||
- **Format specification** (`quantizedmeshformat.h`) - Cesium Quantized Mesh adapted for MaSzyna
|
|
||||||
- **Triangulator** (`terraincooker_tin.{h,cpp}`) - CDT-based constrained Delaunay with LOD generation
|
|
||||||
- **Tool integration** (`terraincook -cooktin`) - Export to `.qm` tile files
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
- Multi-LOD generation (4 levels, configurable)
|
|
||||||
- Edge-collapse simplification (~50% vertex reduction)
|
|
||||||
- Seamless tile stitching via edge indices
|
|
||||||
- Constraint edge support (for track corridors)
|
|
||||||
- Quantized vertex positions (uint16, 2mm precision)
|
|
||||||
- Optional zstd compression per tile
|
|
||||||
|
|
||||||
### Output Format
|
|
||||||
```
|
|
||||||
terrain_X_Z_lodN.qm files:
|
|
||||||
- Header: 128 bytes (bounds, counts, error metrics)
|
|
||||||
- Vertices: quantized uint16[3] XYZ + uint16[2] UV
|
|
||||||
- Indices: uint16/uint32 triangle list
|
|
||||||
- Edge indices: north/south/east/west for stitching
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚧 Remaining (Phase 2: Runtime)
|
|
||||||
|
|
||||||
### 1. Loader (Critical Path)
|
|
||||||
**File**: `rendering/terraintileloader.{cppm,cpp}`
|
|
||||||
|
|
||||||
Current: Reads `.ehf` heightfield tiles
|
|
||||||
Needs: Read `.qm` Quantized Mesh tiles
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
struct quantizedmesh_payload {
|
|
||||||
std::vector<vertex_packed> vertices; // dequantized on load
|
|
||||||
std::vector<std::uint32_t> indices;
|
|
||||||
std::vector<std::uint16_t> edge_indices[4]; // N/S/E/W
|
|
||||||
// ... reuse existing threading/priority logic
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Estimate**: 1-2 days (parallel to heightfield reader, reuse thread pool)
|
|
||||||
|
|
||||||
### 2. Renderer (Main Integration)
|
|
||||||
**File**: `rendering/terrainclipmap.{cppm,cpp}`
|
|
||||||
|
|
||||||
Current: Texture array + compute shader
|
|
||||||
Needs: Vertex buffer pool + index buffers
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
- Replace `terrain_tile_pool` (texture arrays) → vertex buffer pool
|
|
||||||
- Upload vertices as `VBO` instead of texture layers
|
|
||||||
- Per-LOD index buffers (already have LOD selection logic)
|
|
||||||
- Edge stitching: sample coarser LOD along tile borders
|
|
||||||
|
|
||||||
**Estimate**: 2-3 days (shader changes, buffer management)
|
|
||||||
|
|
||||||
### 3. Material Blending (Polish)
|
|
||||||
Current: Material splat via texture
|
|
||||||
Needs: Per-vertex material indices
|
|
||||||
|
|
||||||
Options:
|
|
||||||
- A) Per-vertex material index → texture array lookup (1 extra attrib)
|
|
||||||
- B) Bake splat into vertex colors (simpler, less flexible)
|
|
||||||
- C) Material ID texture (render-to-texture during cook)
|
|
||||||
|
|
||||||
**Estimate**: 1 day
|
|
||||||
|
|
||||||
### 4. Constraint Extraction (Quality)
|
|
||||||
Current: Manual constraint input
|
|
||||||
Needs: Auto-generate from track geometry
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
void extract_track_corridor(
|
|
||||||
std::vector<track_segment> const &Tracks,
|
|
||||||
double CorridorWidth,
|
|
||||||
terrain::tin_cooker &Cooker
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Parse track beziers → sample → offset by width → constraints
|
|
||||||
**Estimate**: 1 day (track geometry already available)
|
|
||||||
|
|
||||||
## 📊 Expected Results
|
|
||||||
|
|
||||||
Based on `binary-scene-format-plan.md` measurements:
|
|
||||||
|
|
||||||
| Metric | Heightfield (2m) | TIN (adaptive) | Improvement |
|
|
||||||
|--------|------------------|----------------|-------------|
|
|
||||||
| **File size** | 186 MB | ~100 MB | 46% smaller |
|
|
||||||
| **Triangles** | 8.8M | ~4.4M | 50% fewer |
|
|
||||||
| **Memory (clipmap)** | ~250 MB | ~150 MB | 40% less |
|
|
||||||
| **Detail corridor** | Uniform | Adaptive | Better where needed |
|
|
||||||
| **Detail field** | Oversampled | Right-sized | No waste |
|
|
||||||
|
|
||||||
## 🎯 Next Session Plan
|
|
||||||
|
|
||||||
**Option A: Complete the pipeline (loader + renderer)**
|
|
||||||
- Implement `.qm` loader (reuse existing thread pool)
|
|
||||||
- Adapt clipmap to render from vertex buffers
|
|
||||||
- Test on l204 terrain tiles
|
|
||||||
- Performance comparison vs heightfield
|
|
||||||
|
|
||||||
**Option B: Validate triangulation quality first**
|
|
||||||
- Add preview export (triangle density heatmap)
|
|
||||||
- Verify edge constraints preserved
|
|
||||||
- Check simplification doesn't create artifacts
|
|
||||||
- Tune LOD error thresholds
|
|
||||||
|
|
||||||
**Recommendation**: **Option A** - finish the working pipeline, then iterate on quality.
|
|
||||||
Rationale: Can't properly evaluate triangulation quality without seeing it rendered.
|
|
||||||
|
|
||||||
## 🔧 Build Notes
|
|
||||||
|
|
||||||
### Missing: vcpkg manifest entry for CDT
|
|
||||||
Add to `vcpkg.json` (if using manifest mode):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dependencies": [
|
|
||||||
"cdt"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Or install globally:
|
|
||||||
```bash
|
|
||||||
vcpkg install cdt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Compiler flags
|
|
||||||
CDT is header-only, requires C++17+. Already satisfied (project uses C++20).
|
|
||||||
|
|
||||||
## 📝 Code Quality
|
|
||||||
|
|
||||||
All new code follows project conventions:
|
|
||||||
- MPL-2.0 license headers
|
|
||||||
- Comments explaining "why", not "what"
|
|
||||||
- Shared interface with heightfield cooker (can swap at runtime)
|
|
||||||
- No external dependencies beyond CDT (which is header-only)
|
|
||||||
|
|
||||||
## 🐛 Known Limitations (TODO)
|
|
||||||
|
|
||||||
1. **Simplification is naive** - uses height error only, should consider slope/curvature
|
|
||||||
2. **Material assignment is placeholder** - needs proper per-vertex material baking
|
|
||||||
3. **No meshlet support yet** - extension defined but not generated
|
|
||||||
4. **Edge stitching untested** - need actual rendering to verify no cracks
|
|
||||||
|
|
||||||
## ⏱️ Timeline Estimate
|
|
||||||
|
|
||||||
- **Loader**: 1-2 days
|
|
||||||
- **Renderer**: 2-3 days
|
|
||||||
- **Material blending**: 1 day
|
|
||||||
- **Constraint extraction**: 1 day
|
|
||||||
- **Testing & tuning**: 2-3 days
|
|
||||||
|
|
||||||
**Total**: ~1.5-2 weeks to complete TIN terrain system
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Ready to continue with loader implementation?** All infrastructure is in place.
|
|
||||||
@@ -1512,26 +1512,24 @@ debug_panel::update_section_renderer( std::vector<text_line> &Output ) {
|
|||||||
auto const slash { field.path.find_last_of( "/\\" ) };
|
auto const slash { field.path.find_last_of( "/\\" ) };
|
||||||
Output.emplace_back(
|
Output.emplace_back(
|
||||||
"Terrain: " + ( slash == std::string::npos ? field.path : field.path.substr( slash + 1 ) )
|
"Terrain: " + ( slash == std::string::npos ? field.path : field.path.substr( slash + 1 ) )
|
||||||
+ ", grid " + to_string( field.gridstep, 1 ) + " m, tile " + to_string( field.tilesize, 0 ) + " m"
|
+ ", finest tile " + to_string( field.tilesize, 0 ) + " m, " + std::to_string( field.levels ) + " levels"
|
||||||
+ ", loaded out to " + to_string( field.range, 0 ) + " m",
|
+ ", loaded out to " + to_string( field.range, 0 ) + " m, detail " + to_string( field.detail, 1 ) + " px",
|
||||||
Global.UITextColor );
|
Global.UITextColor );
|
||||||
Output.emplace_back(
|
Output.emplace_back(
|
||||||
" tiles: " + std::to_string( stats.inview ) + " in range, "
|
" tiles: " + std::to_string( stats.chosen ) + " chosen, "
|
||||||
+ std::to_string( stats.drawn ) + " drawn, "
|
+ std::to_string( stats.drawn ) + " drawn (" + std::to_string( stats.triangles / 1000 ) + "k triangles), "
|
||||||
+ std::to_string( stats.resident ) + " on gpu (" + std::to_string( stats.texturebytes / 1048576 ) + " MB), "
|
+ std::to_string( stats.resident ) + " on gpu (" + std::to_string( stats.gpubytes / 1048576 ) + " MB), "
|
||||||
+ std::to_string( stats.wanted ) + " wanted, " + std::to_string( field.backlog ) + " in loader, "
|
+ std::to_string( stats.wanted ) + " wanted, " + std::to_string( field.backlog ) + " in loader, "
|
||||||
+ std::to_string( stats.uploaded ) + " uploaded, " + std::to_string( stats.dropped ) + " dropped stale",
|
+ std::to_string( stats.uploaded ) + " uploaded, " + std::to_string( stats.dropped ) + " dropped stale",
|
||||||
Global.UITextColor );
|
Global.UITextColor );
|
||||||
std::string levels { " levels:" };
|
std::string levels { " levels:" };
|
||||||
for( std::uint32_t level = 0; ( level < field.levels ) && ( level < stats.perlevel.size() ); ++level ) {
|
for( std::uint32_t level = 0; ( level < field.levels ) && ( level < stats.perlevel.size() ); ++level ) {
|
||||||
auto const last { level + 1 == field.levels };
|
|
||||||
levels +=
|
levels +=
|
||||||
" L" + std::to_string( level )
|
" L" + std::to_string( level )
|
||||||
+ ( last ? " beyond " + to_string( terrain_level_distance( level > 0 ? level - 1 : 0, field.finest ), 0 )
|
+ " (" + to_string( field.leveltile[ level ], 0 ) + " m tile, "
|
||||||
: " <" + to_string( terrain_level_distance( level, field.finest ), 0 ) )
|
+ to_string( field.levelerror[ level ], 2 ) + " m off): "
|
||||||
+ " m (" + to_string( field.gridstep * static_cast<float>( 1u << level ), 0 ) + " m grid): "
|
|
||||||
+ std::to_string( stats.perlevel[ level ] );
|
+ std::to_string( stats.perlevel[ level ] );
|
||||||
if( false == last ) { levels += " |"; }
|
if( level + 1 < field.levels ) { levels += " |"; }
|
||||||
}
|
}
|
||||||
Output.emplace_back( levels, Global.UITextColor );
|
Output.emplace_back( levels, Global.UITextColor );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2932,12 +2932,16 @@ void opengl33_renderer::Render_terrain()
|
|||||||
if (status.path.empty())
|
if (status.path.empty())
|
||||||
{
|
{
|
||||||
status.path = field.path();
|
status.path = field.path();
|
||||||
status.gridstep = field.gridstep();
|
|
||||||
status.tilesize = field.tilesize();
|
status.tilesize = field.tilesize();
|
||||||
status.levels = field.levels();
|
status.levels = field.levels();
|
||||||
|
for (std::uint32_t level = 0; level < field.levels() && level < status.leveltile.size(); ++level)
|
||||||
|
{
|
||||||
|
status.leveltile[level] = field.leveltile(level);
|
||||||
|
status.levelerror[level] = field.levelerror(level);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
status.range = field.range();
|
status.range = field.range();
|
||||||
status.finest = field.finest();
|
status.detail = field.detailpixels();
|
||||||
status.backlog = field.backlog();
|
status.backlog = field.backlog();
|
||||||
status.stats = field.stats();
|
status.stats = field.stats();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,28 +9,27 @@ http://mozilla.org/MPL/2.0/.
|
|||||||
|
|
||||||
module;
|
module;
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "glad/glad.h"
|
#include "glad/glad.h"
|
||||||
|
|
||||||
#include "scene/heightfieldformat.h"
|
#include "scene/quantizedmeshreader.h"
|
||||||
#include "scene/heightfieldreader.h"
|
|
||||||
|
|
||||||
module eu07.rendering.terrainclipmap;
|
module eu07.rendering.terrainclipmap;
|
||||||
import eu07.glm;
|
import eu07.glm;
|
||||||
import eu07.utilities.logs;
|
import eu07.utilities.logs;
|
||||||
import eu07.model.texture;
|
import eu07.model.texture;
|
||||||
|
|
||||||
|
|
||||||
terrain_clipmap::~terrain_clipmap() {
|
terrain_clipmap::~terrain_clipmap() {
|
||||||
|
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,19 +38,34 @@ terrain_clipmap::open( std::string const &Path ) {
|
|||||||
|
|
||||||
close();
|
close();
|
||||||
|
|
||||||
if( ( false == m_reader.open( Path ) )
|
if( false == m_reader.open( Path ) ) {
|
||||||
|| ( false == m_loader.open( Path ) ) ) {
|
ErrorLog( "Terrain: cannot read cooked terrain \"" + Path + "\"" );
|
||||||
ErrorLog( "Terrain: cannot open heightfield \"" + Path + "\"" );
|
return false;
|
||||||
|
}
|
||||||
|
m_table = m_reader.table();
|
||||||
|
m_levels = std::min<std::uint32_t>( m_table.levels, terrain_maxlevels );
|
||||||
|
m_finesttile = static_cast<float>( m_table.tilesize( m_levels - 1 ) );
|
||||||
|
// every vertex of every tile is measured from the middle of the terrain, so that the whole of
|
||||||
|
// it fits in floats: at ten kilometres a float still holds a millimetre
|
||||||
|
m_origin = glm::dvec3 {
|
||||||
|
m_table.west + m_table.side * 0.5,
|
||||||
|
( m_table.lowest + m_table.highest ) * 0.5,
|
||||||
|
m_table.north + m_table.side * 0.5 };
|
||||||
|
|
||||||
|
if( false == m_loader.open( Path, m_origin.x, m_origin.y, m_origin.z ) ) {
|
||||||
|
ErrorLog( "Terrain: cannot open \"" + Path + "\" for reading" );
|
||||||
|
close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if( false == m_arena.create() ) {
|
||||||
|
ErrorLog( "Terrain: cannot allocate the terrain buffers" );
|
||||||
close();
|
close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
m_path = Path;
|
|
||||||
build_indices();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
gl::shader vertex( "terrainclipmap.vert" );
|
gl::shader vertex( "terrainmesh.vert" );
|
||||||
gl::shader fragment( "terrainclipmap.frag" );
|
gl::shader fragment( "terrainmesh.frag" );
|
||||||
m_shader.emplace( std::vector<std::reference_wrapper<gl::shader const>>( { vertex, fragment } ) );
|
m_shader.emplace( std::vector<std::reference_wrapper<gl::shader const>>( { vertex, fragment } ) );
|
||||||
}
|
}
|
||||||
catch( gl::shader_exception const &error ) {
|
catch( gl::shader_exception const &error ) {
|
||||||
@@ -59,48 +73,44 @@ terrain_clipmap::open( std::string const &Path ) {
|
|||||||
close();
|
close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if( false == m_ground.create( m_reader.materials(), m_reader.material_scales() ) ) {
|
|
||||||
|
std::vector<std::string> names;
|
||||||
|
std::vector<float> repeats;
|
||||||
|
names.reserve( m_table.materials.size() );
|
||||||
|
repeats.reserve( m_table.materials.size() );
|
||||||
|
for( auto const &material : m_table.materials ) {
|
||||||
|
names.push_back( material.name );
|
||||||
|
repeats.push_back( material.repeat );
|
||||||
|
}
|
||||||
|
if( false == m_ground.create( names, repeats ) ) {
|
||||||
|
ErrorLog( "Terrain: ground textures failed" );
|
||||||
close();
|
close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
for( std::uint32_t level = 0; level < m_reader.levels(); ++level ) {
|
|
||||||
m_pools.emplace_back( std::make_unique<terrain_tile_pool>( m_reader.samples_per_side( level ) ) );
|
|
||||||
}
|
|
||||||
m_instances.resize( m_reader.levels() );
|
|
||||||
m_instancebuffer.emplace();
|
|
||||||
::glGenTextures( 1, &m_instancetexture );
|
|
||||||
opengl_texture::reset_unit_cache();
|
|
||||||
|
|
||||||
m_vao.emplace();
|
|
||||||
resolve_uniforms();
|
resolve_uniforms();
|
||||||
|
m_path = Path;
|
||||||
m_ready = true;
|
m_ready = true;
|
||||||
|
|
||||||
WriteLog(
|
WriteLog(
|
||||||
"Terrain: " + Path + ", " + std::to_string( m_reader.tiles().size() ) + " tiles, "
|
"Terrain: " + Path + ", " + std::to_string( m_levels ) + " levels, finest tile "
|
||||||
+ std::to_string( m_reader.levels() ) + " levels, grid "
|
+ std::to_string( static_cast<int>( m_finesttile ) ) + " m over "
|
||||||
+ std::to_string( m_reader.gridstep() ) + " m, tile " + std::to_string( m_reader.tilesize() ) + " m" );
|
+ std::to_string( static_cast<int>( m_table.side ) ) + " m, "
|
||||||
|
+ std::to_string( m_reader.present().size() ) + " tiles, "
|
||||||
|
+ std::to_string( m_table.materials.size() ) + " ground materials" );
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// uniform locations do not change once the program is linked, so they are looked up here
|
// uniform locations do not change once the program is linked, so they are looked up here rather
|
||||||
// rather than per draw
|
// than per draw
|
||||||
void
|
void
|
||||||
terrain_clipmap::resolve_uniforms() {
|
terrain_clipmap::resolve_uniforms() {
|
||||||
|
|
||||||
auto const program { static_cast<GLuint>( *m_shader ) };
|
auto const program { static_cast<GLuint>( *m_shader ) };
|
||||||
m_uniforms.samplestep = ::glGetUniformLocation( program, "samplestep" );
|
m_uniforms.origin = ::glGetUniformLocation( program, "terrainorigin" );
|
||||||
m_uniforms.side = ::glGetUniformLocation( program, "side" );
|
m_uniforms.world = ::glGetUniformLocation( program, "terrainworld" );
|
||||||
m_uniforms.morph = ::glGetUniformLocation( program, "morph" );
|
|
||||||
m_uniforms.heightbias = ::glGetUniformLocation( program, "heightbias" );
|
|
||||||
m_uniforms.heightscale = ::glGetUniformLocation( program, "heightscale" );
|
|
||||||
m_uniforms.nodata = ::glGetUniformLocation( program, "nodata" );
|
|
||||||
m_uniforms.heights = ::glGetUniformLocation( program, "heights" );
|
|
||||||
m_uniforms.materials = ::glGetUniformLocation( program, "materials" );
|
|
||||||
m_uniforms.instances = ::glGetUniformLocation( program, "instances" );
|
|
||||||
m_uniforms.ground = ::glGetUniformLocation( program, "ground" );
|
m_uniforms.ground = ::glGetUniformLocation( program, "ground" );
|
||||||
m_uniforms.materialtable = ::glGetUniformLocation( program, "materialtable" );
|
m_uniforms.materials = ::glGetUniformLocation( program, "materialtable" );
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
@@ -108,367 +118,368 @@ terrain_clipmap::close() {
|
|||||||
|
|
||||||
// the loader first, so nothing arrives for tiles that are being torn down
|
// the loader first, so nothing arrives for tiles that are being torn down
|
||||||
m_loader.close();
|
m_loader.close();
|
||||||
|
m_tiles.clear();
|
||||||
|
m_chosen.clear();
|
||||||
|
m_wishes.clear();
|
||||||
m_wanted.clear();
|
m_wanted.clear();
|
||||||
m_arrived.clear();
|
m_arrived.clear();
|
||||||
m_inrange.clear();
|
m_asked.clear();
|
||||||
m_wantedlevel.clear();
|
m_readiness.clear();
|
||||||
m_scanrange = -1.f;
|
m_arena.destroy();
|
||||||
m_tiles.clear();
|
|
||||||
m_pools.clear();
|
|
||||||
m_indices.clear();
|
|
||||||
m_indexcounts.clear();
|
|
||||||
m_instances.clear();
|
|
||||||
m_instancebuffer.reset();
|
|
||||||
if( m_instancetexture != 0 ) { ::glDeleteTextures( 1, &m_instancetexture ); m_instancetexture = 0; }
|
|
||||||
m_ground.destroy();
|
m_ground.destroy();
|
||||||
m_vao.reset();
|
|
||||||
m_shader.reset();
|
m_shader.reset();
|
||||||
m_reader.close();
|
m_reader.close();
|
||||||
|
m_table = {};
|
||||||
m_stats = {};
|
m_stats = {};
|
||||||
|
m_walked = false;
|
||||||
|
m_walkrange = -1.f;
|
||||||
m_ready = false;
|
m_ready = false;
|
||||||
|
m_path.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// index buffers, one per mip level. the grid they describe is the same shape at every
|
float
|
||||||
// level, only smaller, and the vertices themselves never exist in memory: the shader
|
terrain_clipmap::leveltile( std::uint32_t const Level ) const {
|
||||||
// derives a sample's row and column from gl_VertexID
|
|
||||||
void
|
|
||||||
terrain_clipmap::build_indices() {
|
|
||||||
|
|
||||||
m_indices.resize( m_reader.levels() );
|
return static_cast<float>( m_table.tilesize( Level ) );
|
||||||
m_indexcounts.resize( m_reader.levels() );
|
|
||||||
|
|
||||||
std::vector<std::uint32_t> indices;
|
|
||||||
for( std::uint32_t level = 0; level < m_reader.levels(); ++level ) {
|
|
||||||
|
|
||||||
auto const side { m_reader.samples_per_side( level ) };
|
|
||||||
indices.clear();
|
|
||||||
indices.reserve( static_cast<std::size_t>( side - 1 ) * ( side - 1 ) * 6 );
|
|
||||||
|
|
||||||
for( std::uint32_t row = 0; row + 1 < side; ++row ) {
|
|
||||||
for( std::uint32_t column = 0; column + 1 < side; ++column ) {
|
|
||||||
auto const topleft { row * side + column };
|
|
||||||
auto const topright { topleft + 1 };
|
|
||||||
auto const bottomleft { topleft + side };
|
|
||||||
auto const bottomright { bottomleft + 1 };
|
|
||||||
indices.push_back( topleft ); indices.push_back( bottomleft ); indices.push_back( topright );
|
|
||||||
indices.push_back( topright ); indices.push_back( bottomleft ); indices.push_back( bottomright );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
m_indexcounts[ level ] = static_cast<std::uint32_t>( indices.size() );
|
|
||||||
m_indices[ level ] = std::make_unique<gl::buffer>();
|
|
||||||
m_indices[ level ]->allocate(
|
|
||||||
gl::buffer::ELEMENT_ARRAY_BUFFER, indices.size() * sizeof( std::uint32_t ), GL_STATIC_DRAW );
|
|
||||||
m_indices[ level ]->upload(
|
|
||||||
gl::buffer::ELEMENT_ARRAY_BUFFER, indices.data(), 0, indices.size() * sizeof( std::uint32_t ) );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
float
|
||||||
|
terrain_clipmap::levelerror( std::uint32_t const Level ) const {
|
||||||
|
|
||||||
std::uint32_t
|
return m_table.error( Level );
|
||||||
terrain_clipmap::level_for( double const Distance ) const {
|
|
||||||
|
|
||||||
if( Distance <= m_finest ) { return 0; }
|
|
||||||
auto const steps { static_cast<std::uint32_t>( std::log2( Distance / m_finest ) ) + 1 };
|
|
||||||
return std::min( steps, m_reader.levels() - 1 );
|
|
||||||
}
|
|
||||||
|
|
||||||
void
|
|
||||||
terrain_clipmap::upload( terrain_tile_loader::payload const &Tile ) {
|
|
||||||
|
|
||||||
auto const tilekey { key( Tile.tile.x, Tile.tile.z ) };
|
|
||||||
auto const existing { m_tiles.find( tilekey ) };
|
|
||||||
if( existing != m_tiles.end() ) {
|
|
||||||
// a level change moves the tile to another pool
|
|
||||||
retire( existing->second );
|
|
||||||
m_tiles.erase( existing );
|
|
||||||
}
|
|
||||||
|
|
||||||
auto &pool { *m_pools[ Tile.tile.level ] };
|
|
||||||
resident_tile tile;
|
|
||||||
tile.x = Tile.tile.x;
|
|
||||||
tile.z = Tile.tile.z;
|
|
||||||
tile.level = Tile.tile.level;
|
|
||||||
tile.slot = pool.acquire();
|
|
||||||
tile.lastused = m_frame;
|
|
||||||
pool.upload( tile.slot, Tile.heights.data(), Tile.materials.data() );
|
|
||||||
|
|
||||||
if( auto const *description { m_reader.find( Tile.tile.x, Tile.tile.z ) } ) {
|
|
||||||
tile.minheight = description->minheight;
|
|
||||||
tile.maxheight = description->maxheight;
|
|
||||||
}
|
|
||||||
|
|
||||||
++m_stats.uploaded;
|
|
||||||
m_tiles.emplace( tilekey, tile );
|
|
||||||
}
|
|
||||||
|
|
||||||
void
|
|
||||||
terrain_clipmap::retire( resident_tile const &Tile ) {
|
|
||||||
|
|
||||||
m_pools[ Tile.level ]->release( Tile.slot );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
terrain_clipmap::detail( double const Pixelsperunit, double const Pixels ) {
|
terrain_clipmap::detail( double const Pixelsperunit, double const Pixels ) {
|
||||||
|
|
||||||
m_finestwanted = terrain_finest_range( m_reader.gridstep(), m_reader.tilesize(), Pixelsperunit, Pixels );
|
m_pixelsperunit = Pixelsperunit;
|
||||||
|
m_detail = std::max( 0.1, Pixels );
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
terrain_clipmap::scan( glm::dvec3 const &Viewpoint ) {
|
terrain_clipmap::square( quantizedmesh::tile_address const &Tile, double &West, double &North, double &Side ) const {
|
||||||
|
|
||||||
m_scanpoint = Viewpoint;
|
Side = m_table.tilesize( Tile.level );
|
||||||
m_scanrange = m_range;
|
West = m_table.west + Tile.x * Side;
|
||||||
m_finest = ( m_finestwanted > 0.0 ? m_finestwanted : terrain_finest_tiles * m_reader.tilesize() );
|
North = m_table.north + Tile.z * Side;
|
||||||
m_inrange.clear();
|
}
|
||||||
m_wantedlevel.clear();
|
|
||||||
|
|
||||||
auto const tilesize { static_cast<double>( m_reader.tilesize() ) };
|
// the distance to the nearest point of the tile's square, not to its centre: a coarse tile is
|
||||||
auto const reach { std::max( 1, static_cast<std::int32_t>( m_range / tilesize ) + 1 ) };
|
// kilometres across, and the one the camera stands on has its centre far outside any draw range
|
||||||
auto const centrex { m_reader.tile_x( Viewpoint.x ) };
|
double
|
||||||
auto const centrez { m_reader.tile_z( Viewpoint.z ) };
|
terrain_clipmap::distance_to( quantizedmesh::tile_address const &Tile, glm::dvec3 const &Viewpoint ) const {
|
||||||
std::vector<std::pair<double, terrain_tile_loader::request>> wishes;
|
|
||||||
|
|
||||||
for( std::int32_t z = centrez - reach; z <= centrez + reach; ++z ) {
|
double west { 0.0 }, north { 0.0 }, side { 0.0 };
|
||||||
for( std::int32_t x = centrex - reach; x <= centrex + reach; ++x ) {
|
square( Tile, west, north, side );
|
||||||
|
auto const nearestx { std::clamp( Viewpoint.x, west, west + side ) };
|
||||||
|
auto const nearestz { std::clamp( Viewpoint.z, north, north + side ) };
|
||||||
|
return glm::length( glm::dvec2 { nearestx - Viewpoint.x, nearestz - Viewpoint.z } );
|
||||||
|
}
|
||||||
|
|
||||||
if( false == m_reader.contains( x, z ) ) { continue; }
|
bool
|
||||||
|
terrain_clipmap::present( quantizedmesh::tile_address const &Tile ) const {
|
||||||
|
|
||||||
// distance to the nearest point of the tile, not to its centre. a regional
|
return m_reader.present().contains( quantizedmesh::tile_key( Tile ) );
|
||||||
// field has tiles kilometres across, and a tile the camera is standing on
|
}
|
||||||
// can have its centre far outside the draw range
|
|
||||||
auto const west { x * tilesize };
|
|
||||||
auto const north { z * tilesize };
|
|
||||||
auto const nearestx { std::clamp( Viewpoint.x, west, west + tilesize ) };
|
|
||||||
auto const nearestz { std::clamp( Viewpoint.z, north, north + tilesize ) };
|
|
||||||
auto const distance {
|
|
||||||
glm::length( glm::dvec2 { nearestx - Viewpoint.x, nearestz - Viewpoint.z } ) };
|
|
||||||
if( distance > m_range ) { continue; }
|
|
||||||
|
|
||||||
auto const tilekey { key( x, z ) };
|
// Whether the ground a tile stands for is close enough to the real thing. The tile's error, at its
|
||||||
auto const level { level_for( distance ) };
|
// distance, covers so many pixels; while that is fewer than the detail asks for there is nothing to
|
||||||
m_inrange.push_back( { tilekey, level } );
|
// be had by splitting it. The tile the camera stands on is always split, whatever its error, so that
|
||||||
m_wantedlevel.emplace( tilekey, level );
|
// what is underfoot is the ground as the scenery drew it.
|
||||||
|
bool
|
||||||
|
terrain_clipmap::good_enough( quantizedmesh::tile_address const &Tile, double const Distance ) const {
|
||||||
|
|
||||||
auto const resident { m_tiles.find( tilekey ) };
|
if( Tile.level + 1 >= m_levels ) { return true; }
|
||||||
if( ( resident == m_tiles.end() ) || ( resident->second.level != level ) ) {
|
if( Distance <= 0.0 ) { return false; }
|
||||||
wishes.push_back( { distance, { x, z, level } } );
|
return terrain_level_enough( m_table.error( Tile.level ), Distance, m_pixelsperunit, m_detail );
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Walks down to the levels the screen wants and asks the loader for whatever of them is not on the
|
||||||
|
// card, deepest tile of the wanted cut included - not only the tiles one level down. Asking one level
|
||||||
|
// at a time is what a first attempt at this did, and it stops dead: a tile whose children are all
|
||||||
|
// resident asks for nothing, while what it is waiting for is their children.
|
||||||
|
//
|
||||||
|
// Returns whether the cut under this tile, and the tile itself where the cut ends at it, is there. A
|
||||||
|
// tile whose subtree is not ready yet is asked for as well, so that something coarse covers the
|
||||||
|
// ground rather than a hole while the rest is read.
|
||||||
|
bool
|
||||||
|
terrain_clipmap::need( quantizedmesh::tile_address const &Tile, glm::dvec3 const &Viewpoint ) {
|
||||||
|
|
||||||
|
if( false == present( Tile ) ) { return true; } // no ground here to draw
|
||||||
|
|
||||||
|
auto const distance { distance_to( Tile, Viewpoint ) };
|
||||||
|
if( distance > m_range ) { return true; } // nothing wanted this far out
|
||||||
|
|
||||||
|
auto const key { quantizedmesh::tile_key( Tile ) };
|
||||||
|
auto const resident { m_tiles.contains( key ) };
|
||||||
|
|
||||||
|
if( true == good_enough( Tile, distance ) ) {
|
||||||
|
if( false == resident ) { ask_for( Tile, distance ); }
|
||||||
|
m_readiness.emplace( key, resident );
|
||||||
|
return resident;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto ready { true };
|
||||||
|
for( std::int32_t stepz = 0; stepz < 2; ++stepz ) {
|
||||||
|
for( std::int32_t stepx = 0; stepx < 2; ++stepx ) {
|
||||||
|
// every child is asked, so the whole cut is queued at once rather than a level a frame
|
||||||
|
ready = need( { Tile.level + 1, Tile.x * 2 + stepx, Tile.z * 2 + stepz }, Viewpoint ) && ready;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if( ( false == ready ) && ( false == resident ) ) { ask_for( Tile, distance ); }
|
||||||
|
m_readiness.emplace( key, ready );
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
terrain_clipmap::choose( quantizedmesh::tile_address const &Tile, glm::dvec3 const &Viewpoint ) {
|
||||||
|
|
||||||
|
if( false == present( Tile ) ) { return; }
|
||||||
|
|
||||||
|
auto const distance { distance_to( Tile, Viewpoint ) };
|
||||||
|
if( distance > m_range ) { return; }
|
||||||
|
|
||||||
|
auto const key { quantizedmesh::tile_key( Tile ) };
|
||||||
|
auto const ready { m_readiness.find( key ) };
|
||||||
|
if( ( ready != m_readiness.end() ) && ( true == ready->second )
|
||||||
|
&& ( false == good_enough( Tile, distance ) ) ) {
|
||||||
|
// the whole cut under this tile is on the card: draw that instead
|
||||||
|
for( std::int32_t stepz = 0; stepz < 2; ++stepz ) {
|
||||||
|
for( std::int32_t stepx = 0; stepx < 2; ++stepx ) {
|
||||||
|
choose( { Tile.level + 1, Tile.x * 2 + stepx, Tile.z * 2 + stepz }, Viewpoint );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// either this level is good enough, or what is below it is still being read: this tile covers
|
||||||
|
// the ground meanwhile, and nothing under it is drawn, so nothing is drawn twice
|
||||||
|
if( true == m_tiles.contains( key ) ) { m_chosen.push_back( key ); }
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
terrain_clipmap::ask_for( quantizedmesh::tile_address const &Tile, double const Distance ) {
|
||||||
|
|
||||||
|
if( false == m_asked.insert( quantizedmesh::tile_key( Tile ) ).second ) { return; }
|
||||||
|
m_wishes.emplace_back( Distance, Tile );
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
terrain_clipmap::walk( glm::dvec3 const &Viewpoint ) {
|
||||||
|
|
||||||
|
m_walkpoint = Viewpoint;
|
||||||
|
m_walkrange = m_range;
|
||||||
|
m_walkdetail = m_detail;
|
||||||
|
m_walked = true;
|
||||||
|
m_chosen.clear();
|
||||||
|
m_wishes.clear();
|
||||||
|
m_asked.clear();
|
||||||
|
m_readiness.clear();
|
||||||
|
|
||||||
|
need( { 0, 0, 0 }, Viewpoint );
|
||||||
|
choose( { 0, 0, 0 }, Viewpoint );
|
||||||
|
|
||||||
// nearest first, so the ground under the camera is never waiting behind the horizon
|
// nearest first, so the ground under the camera is never waiting behind the horizon
|
||||||
std::sort( wishes.begin(), wishes.end(),
|
std::sort( m_wishes.begin(), m_wishes.end(),
|
||||||
[]( auto const &Left, auto const &Right ) { return Left.first < Right.first; } );
|
[]( auto const &Left, auto const &Right ) { return Left.first < Right.first; } );
|
||||||
m_wanted.clear();
|
m_wanted.clear();
|
||||||
for( auto const &wish : wishes ) { m_wanted.push_back( wish.second ); }
|
m_wanted.reserve( m_wishes.size() );
|
||||||
|
for( auto const &wish : m_wishes ) { m_wanted.push_back( wish.second ); }
|
||||||
m_loader.want( m_wanted );
|
m_loader.want( m_wanted );
|
||||||
|
|
||||||
|
m_stats.chosen = m_chosen.size();
|
||||||
|
m_stats.wanted = m_wanted.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
terrain_clipmap::put_in( terrain_tile_loader::payload const &Tile ) {
|
||||||
|
|
||||||
|
auto const key { quantizedmesh::tile_key( Tile.data.tile ) };
|
||||||
|
auto const existing { m_tiles.find( key ) };
|
||||||
|
if( existing != m_tiles.end() ) {
|
||||||
|
// already there; nothing about a tile changes between reads
|
||||||
|
++m_stats.dropped;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto piece { m_arena.put( Tile.data ) };
|
||||||
|
if( false == piece.held() ) { return; }
|
||||||
|
|
||||||
|
resident_tile tile;
|
||||||
|
tile.piece = piece;
|
||||||
|
tile.centre = glm::dvec3 { Tile.data.centrex, Tile.data.centrey, Tile.data.centrez };
|
||||||
|
tile.radius = static_cast<float>( Tile.data.radius );
|
||||||
|
tile.level = Tile.data.tile.level;
|
||||||
|
tile.lastused = m_frame;
|
||||||
|
m_tiles.emplace( key, tile );
|
||||||
|
++m_stats.uploaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What is held is what the walk has been choosing or asking for. Once the buffers are fuller than
|
||||||
|
// the budget, whatever has gone longest without either is given back - never something the walk
|
||||||
|
// wants now, because a coarse tile it is drawing is what stands in for its children while they are
|
||||||
|
// read.
|
||||||
|
void
|
||||||
|
terrain_clipmap::retire_unused() {
|
||||||
|
|
||||||
|
if( m_arena.bytes() <= m_budget ) { return; }
|
||||||
|
|
||||||
|
std::vector<std::pair<std::uint64_t, std::int64_t>> aged;
|
||||||
|
aged.reserve( m_tiles.size() );
|
||||||
|
for( auto const &[ key, tile ] : m_tiles ) {
|
||||||
|
if( tile.lastused != m_frame ) { aged.emplace_back( tile.lastused, key ); }
|
||||||
|
}
|
||||||
|
if( true == aged.empty() ) { return; }
|
||||||
|
std::sort( aged.begin(), aged.end() );
|
||||||
|
|
||||||
|
// a fifth of what is held, so that this does not run every frame
|
||||||
|
auto const letgo { std::max<std::size_t>( 1, m_tiles.size() / 5 ) };
|
||||||
|
for( std::size_t index = 0; ( index < letgo ) && ( index < aged.size() ); ++index ) {
|
||||||
|
auto const found { m_tiles.find( aged[ index ].second ) };
|
||||||
|
if( found == m_tiles.end() ) { continue; }
|
||||||
|
m_arena.take( found->second.piece );
|
||||||
|
m_tiles.erase( found );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
terrain_clipmap::update( glm::dvec3 const &Viewpoint ) {
|
terrain_clipmap::update( glm::dvec3 const &Viewpoint ) {
|
||||||
|
|
||||||
if( false == m_ready ) { return; }
|
if( false == m_ready ) { return; }
|
||||||
|
|
||||||
++m_frame;
|
++m_frame;
|
||||||
|
|
||||||
auto const moved {
|
|
||||||
glm::length( glm::dvec2 { Viewpoint.x - m_scanpoint.x, Viewpoint.z - m_scanpoint.z } ) };
|
|
||||||
// a zoom or a resized window changes the levels; a change under a percent is not worth
|
|
||||||
// redoing the scan for
|
|
||||||
auto const detailchanged {
|
|
||||||
( m_finestwanted > 0.0 ) && ( std::abs( m_finestwanted - m_finest ) > 0.01 * m_finest ) };
|
|
||||||
if( ( m_range != m_scanrange )
|
|
||||||
|| ( true == detailchanged )
|
|
||||||
|| ( moved > m_reader.tilesize() * terrain_rescan_tiles ) ) {
|
|
||||||
scan( Viewpoint );
|
|
||||||
}
|
|
||||||
|
|
||||||
// the tiles in range are touched every frame, which is what render() draws and what
|
|
||||||
// eviction spares
|
|
||||||
m_stats.inview = 0;
|
|
||||||
m_stats.wanted = 0;
|
|
||||||
m_stats.perlevel.fill( 0 );
|
|
||||||
for( auto const &tile : m_inrange ) {
|
|
||||||
auto const resident { m_tiles.find( tile.key ) };
|
|
||||||
if( resident == m_tiles.end() ) {
|
|
||||||
++m_stats.wanted;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// still wanted, at whatever level it has until the right one arrives
|
|
||||||
resident->second.lastused = m_frame;
|
|
||||||
++m_stats.inview;
|
|
||||||
++m_stats.perlevel[ std::min<std::size_t>( resident->second.level, m_stats.perlevel.size() - 1 ) ];
|
|
||||||
if( resident->second.level != tile.level ) { ++m_stats.wanted; }
|
|
||||||
}
|
|
||||||
|
|
||||||
m_arrived.clear();
|
m_arrived.clear();
|
||||||
m_loader.collect( m_arrived, m_uploadsperframe );
|
m_loader.collect( m_arrived, m_putsperframe );
|
||||||
for( auto const &tile : m_arrived ) {
|
for( auto const &tile : m_arrived ) {
|
||||||
if( false == tile.valid ) { continue; }
|
if( false == tile.valid ) { continue; }
|
||||||
// a tile read for where the camera was is put up only if nothing of it is on the gpu
|
// a tile read for where the camera was is still put in: it covers ground, and what is
|
||||||
// yet - some level is better than a hole. otherwise it is dropped, and the level
|
|
||||||
// wanted now is already queued
|
// wanted now is already queued
|
||||||
auto const tilekey { key( tile.tile.x, tile.tile.z ) };
|
put_in( tile );
|
||||||
auto const wanted { m_wantedlevel.find( tilekey ) };
|
|
||||||
auto const stillwanted { ( wanted != m_wantedlevel.end() ) && ( wanted->second == tile.tile.level ) };
|
|
||||||
if( stillwanted || ( false == m_tiles.contains( tilekey ) ) ) {
|
|
||||||
upload( tile );
|
|
||||||
// put up this frame, so drawn this frame
|
|
||||||
m_tiles.find( tilekey )->second.lastused = m_frame;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
++m_stats.dropped;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if( ( m_tiles.size() > m_budget ) && ( m_tiles.size() > m_stats.inview ) ) {
|
auto const moved {
|
||||||
// over budget, and not everything is in view: drop whatever has gone longest without
|
glm::length( glm::dvec2 { Viewpoint.x - m_walkpoint.x, Viewpoint.z - m_walkpoint.z } ) };
|
||||||
// being asked for
|
auto const detailchanged { std::abs( m_detail - m_walkdetail ) > 0.01 * m_walkdetail };
|
||||||
std::vector<std::pair<std::uint64_t, std::int64_t>> aged;
|
if( ( false == m_walked )
|
||||||
for( auto const & [ tilekey, tile ] : m_tiles ) {
|
|| ( m_range != m_walkrange )
|
||||||
if( tile.lastused != m_frame ) { aged.emplace_back( tile.lastused, tilekey ); }
|
|| ( true == detailchanged )
|
||||||
}
|
|| ( moved > m_finesttile * terrain_rescan_tiles )
|
||||||
auto const excess { std::min( aged.size(), m_tiles.size() - m_budget ) };
|
|| ( false == m_arrived.empty() ) ) {
|
||||||
std::partial_sort( aged.begin(), aged.begin() + excess, aged.end() );
|
// a tile that has just arrived lets the walk go deeper where it was drawing a coarse one
|
||||||
for( std::size_t index = 0; index < excess; ++index ) {
|
walk( Viewpoint );
|
||||||
auto const victim { m_tiles.find( aged[ index ].second ) };
|
|
||||||
retire( victim->second );
|
|
||||||
m_tiles.erase( victim );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// what the walk settled on is wanted now, and is what eviction spares
|
||||||
|
m_stats.perlevel.fill( 0 );
|
||||||
|
for( auto const key : m_chosen ) {
|
||||||
|
auto const found { m_tiles.find( key ) };
|
||||||
|
if( found == m_tiles.end() ) { continue; }
|
||||||
|
found->second.lastused = m_frame;
|
||||||
|
++m_stats.perlevel[ std::min<std::size_t>( found->second.level, m_stats.perlevel.size() - 1 ) ];
|
||||||
|
}
|
||||||
|
|
||||||
|
retire_unused();
|
||||||
m_stats.resident = m_tiles.size();
|
m_stats.resident = m_tiles.size();
|
||||||
m_stats.texturebytes = 0;
|
m_stats.gpubytes = m_arena.bytes();
|
||||||
for( auto const &pool : m_pools ) { m_stats.texturebytes += pool->bytes(); }
|
|
||||||
report_residency();
|
report_residency();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// residency settles within a second of the camera stopping and then barely moves, so a line is
|
||||||
// residency settles within a second of the camera stopping and then barely moves, so a
|
// written when it shifts by a noticeable amount rather than every frame. that makes the warm figure
|
||||||
// line is written when it shifts by a noticeable amount rather than every frame. that
|
// - what the terrain actually costs - readable from the log
|
||||||
// makes the warm figure - what the terrain actually costs - readable from the log
|
|
||||||
void
|
void
|
||||||
terrain_clipmap::report_residency() {
|
terrain_clipmap::report_residency() {
|
||||||
|
|
||||||
auto const difference {
|
auto const apart {
|
||||||
m_stats.resident > m_reportedresidency ?
|
[]( std::size_t const Left, std::size_t const Right ) {
|
||||||
m_stats.resident - m_reportedresidency :
|
return Left > Right ? Left - Right : Right - Left; } };
|
||||||
m_reportedresidency - m_stats.resident };
|
auto const moved {
|
||||||
if( ( m_reported == true ) && ( difference < 16 ) ) { return; }
|
( apart( m_stats.resident, m_reportedresidency ) >= 8 )
|
||||||
|
|| ( apart( m_stats.drawn, m_reporteddrawn ) >= 4 ) };
|
||||||
|
// at most once every couple of seconds, and only when the picture has actually changed: what
|
||||||
|
// this is for is the warm figure, what the terrain settles at, not a line per frame
|
||||||
|
if( ( m_reported == true ) && ( ( false == moved ) || ( m_frame < m_reportedframe + 120 ) ) ) { return; }
|
||||||
|
|
||||||
m_reported = true;
|
m_reported = true;
|
||||||
m_reportedresidency = m_stats.resident;
|
m_reportedresidency = m_stats.resident;
|
||||||
|
m_reporteddrawn = m_stats.drawn;
|
||||||
|
m_reportedframe = m_frame;
|
||||||
WriteLog(
|
WriteLog(
|
||||||
"Terrain: " + std::to_string( m_stats.resident ) + " tiles resident, "
|
"Terrain: " + std::to_string( m_stats.resident ) + " tiles resident, "
|
||||||
+ std::to_string( m_stats.texturebytes / 1048576 ) + " MB of samples, "
|
+ std::to_string( m_stats.gpubytes / 1048576 ) + " MB of mesh, "
|
||||||
+ std::to_string( m_stats.uploaded ) + " uploads so far, "
|
+ std::to_string( m_stats.uploaded ) + " put in so far, "
|
||||||
+ std::to_string( m_stats.drawn ) + " of " + std::to_string( m_stats.inview ) + " in range drawn, finest level out to "
|
+ std::to_string( m_stats.drawn ) + " of " + std::to_string( m_stats.chosen ) + " chosen drawn, "
|
||||||
+ std::to_string( static_cast<int>( m_finest ) ) + " m" );
|
+ std::to_string( m_stats.triangles / 1000 ) + "k triangles, levels "
|
||||||
|
+ [ this ]() {
|
||||||
|
std::string counts;
|
||||||
|
for( std::uint32_t level = 0; level < m_levels; ++level ) {
|
||||||
|
counts += ( level > 0 ? "/" : "" ) + std::to_string( m_stats.perlevel[ level ] );
|
||||||
|
}
|
||||||
|
return counts; }() );
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
terrain_clipmap::render( glm::dvec3 const &Viewpoint, visibility const &Visible ) {
|
terrain_clipmap::render( glm::dvec3 const &Viewpoint, visibility const &Visible ) {
|
||||||
|
|
||||||
if( ( false == m_ready ) || ( true == m_tiles.empty() ) ) { return; }
|
if( ( false == m_ready ) || ( true == m_chosen.empty() ) ) { return; }
|
||||||
|
|
||||||
// textures that finished loading since the last frame go into the ground array first
|
// the ground textures the engine has finished loading since the last frame
|
||||||
m_ground.update();
|
m_ground.update();
|
||||||
|
|
||||||
// what each level draws: the tiles touched by the last update, and seen. a tile's bounding
|
m_counts.clear();
|
||||||
// sphere holds the whole tile, morphing included, since morphing only moves vertices
|
m_offsets.clear();
|
||||||
// towards samples of the same tile
|
m_bases.clear();
|
||||||
auto const tilesize { static_cast<double>( m_reader.tilesize() ) };
|
std::size_t triangles { 0 };
|
||||||
for( auto &instances : m_instances ) { instances.clear(); }
|
for( auto const key : m_chosen ) {
|
||||||
for( auto const & [ tilekey, tile ] : m_tiles ) {
|
auto const found { m_tiles.find( key ) };
|
||||||
if( tile.lastused != m_frame ) { continue; }
|
if( found == m_tiles.end() ) { continue; }
|
||||||
auto const halfheight { 0.5 * ( tile.maxheight - tile.minheight ) };
|
auto const &tile { found->second };
|
||||||
glm::dvec3 const centre {
|
if( false == Visible( tile.centre, tile.radius ) ) { continue; }
|
||||||
( tile.x + 0.5 ) * tilesize, 0.5 * ( tile.minheight + tile.maxheight ), ( tile.z + 0.5 ) * tilesize };
|
m_counts.push_back( static_cast<std::int32_t>( tile.piece.indices ) );
|
||||||
auto const radius {
|
m_offsets.push_back(
|
||||||
static_cast<float>( std::sqrt( 0.5 * tilesize * tilesize + halfheight * halfheight ) ) };
|
reinterpret_cast<void const *>(
|
||||||
if( false == Visible( centre, radius ) ) { continue; }
|
static_cast<std::uintptr_t>( tile.piece.firstindex ) * sizeof( std::uint32_t ) ) );
|
||||||
// relative to the viewpoint, so that the shader never sees a coordinate large enough
|
m_bases.push_back( static_cast<std::int32_t>( tile.piece.firstvertex ) );
|
||||||
// for single precision to matter
|
triangles += tile.piece.indices / 3;
|
||||||
m_instances[ tile.level ].insert(
|
|
||||||
m_instances[ tile.level ].end(),
|
|
||||||
{ static_cast<float>( tile.x * tilesize - Viewpoint.x ),
|
|
||||||
static_cast<float>( -Viewpoint.y ),
|
|
||||||
static_cast<float>( tile.z * tilesize - Viewpoint.z ),
|
|
||||||
static_cast<float>( tile.slot ),
|
|
||||||
static_cast<float>( tile.x * tilesize ),
|
|
||||||
static_cast<float>( tile.z * tilesize ),
|
|
||||||
0.f, 0.f } );
|
|
||||||
}
|
}
|
||||||
|
m_stats.drawn = m_counts.size();
|
||||||
|
m_stats.triangles = triangles;
|
||||||
|
if( true == m_counts.empty() ) { return; }
|
||||||
|
|
||||||
m_shader->bind();
|
m_shader->bind();
|
||||||
m_vao->bind();
|
glm::vec3 const origin {
|
||||||
// the depth buffer is reversed - nearer is greater - so pushing back is a negative offset
|
static_cast<float>( m_origin.x - Viewpoint.x ),
|
||||||
::glEnable( GL_POLYGON_OFFSET_FILL );
|
static_cast<float>( m_origin.y - Viewpoint.y ),
|
||||||
::glPolygonOffset( -1.f - 2.f * static_cast<float>( m_depthrank ), -2.f - 4.f * static_cast<float>( m_depthrank ) );
|
static_cast<float>( m_origin.z - Viewpoint.z ) };
|
||||||
|
::glUniform3fv( m_uniforms.origin, 1, &origin.x );
|
||||||
::glUniform1f( m_uniforms.heightbias, m_reader.height( 0 ) );
|
glm::vec2 const world { static_cast<float>( m_origin.x ), static_cast<float>( m_origin.z ) };
|
||||||
::glUniform1f( m_uniforms.heightscale, m_reader.height( 1 ) - m_reader.height( 0 ) );
|
::glUniform2fv( m_uniforms.world, 1, &world.x );
|
||||||
::glUniform1ui( m_uniforms.nodata, heightfield::nodata );
|
::glUniform1i( m_uniforms.ground, 0 );
|
||||||
::glUniform1i( m_uniforms.heights, 0 );
|
|
||||||
::glUniform1i( m_uniforms.materials, 1 );
|
::glUniform1i( m_uniforms.materials, 1 );
|
||||||
::glUniform1i( m_uniforms.ground, 2 );
|
|
||||||
::glUniform1i( m_uniforms.materialtable, 3 );
|
|
||||||
::glUniform1i( m_uniforms.instances, 4 );
|
|
||||||
|
|
||||||
::glActiveTexture( GL_TEXTURE2 );
|
::glActiveTexture( GL_TEXTURE0 );
|
||||||
::glBindTexture( GL_TEXTURE_2D_ARRAY, m_ground.textures() );
|
::glBindTexture( GL_TEXTURE_2D_ARRAY, m_ground.textures() );
|
||||||
::glActiveTexture( GL_TEXTURE3 );
|
::glActiveTexture( GL_TEXTURE1 );
|
||||||
::glBindTexture( GL_TEXTURE_2D, m_ground.table() );
|
::glBindTexture( GL_TEXTURE_2D, m_ground.table() );
|
||||||
|
|
||||||
std::size_t drawn { 0 };
|
// the ground loses the depth test to whatever lies on it, and a coarser terrain to a finer one
|
||||||
auto const gridstep { m_reader.gridstep() };
|
// describing the same ground. the offset is negative because depth runs the other way here
|
||||||
for( std::uint32_t level = 0; level < m_instances.size(); ++level ) {
|
::glEnable( GL_POLYGON_OFFSET_FILL );
|
||||||
|
::glPolygonOffset(
|
||||||
|
-1.f - 2.f * static_cast<float>( m_depthrank ),
|
||||||
|
-2.f - 4.f * static_cast<float>( m_depthrank ) );
|
||||||
|
|
||||||
auto const &instances { m_instances[ level ] };
|
::glBindVertexArray( m_arena.vertexarray() );
|
||||||
if( true == instances.empty() ) { continue; }
|
::glMultiDrawElementsBaseVertex(
|
||||||
auto const count { instances.size() / 8 };
|
GL_TRIANGLES, m_counts.data(), GL_UNSIGNED_INT,
|
||||||
drawn += count;
|
const_cast<void const **>( m_offsets.data() ),
|
||||||
|
static_cast<GLsizei>( m_counts.size() ), m_bases.data() );
|
||||||
m_instancebuffer->allocate(
|
::glBindVertexArray( 0 );
|
||||||
gl::buffer::TEXTURE_BUFFER, static_cast<GLsizeiptr>( instances.size() * sizeof( float ) ), GL_STREAM_DRAW );
|
|
||||||
m_instancebuffer->upload(
|
|
||||||
gl::buffer::TEXTURE_BUFFER, instances.data(), 0, static_cast<GLsizeiptr>( instances.size() * sizeof( float ) ) );
|
|
||||||
::glActiveTexture( GL_TEXTURE4 );
|
|
||||||
::glBindTexture( GL_TEXTURE_BUFFER, m_instancetexture );
|
|
||||||
::glTexBuffer( GL_TEXTURE_BUFFER, GL_RGBA32F, static_cast<GLuint>( *m_instancebuffer ) );
|
|
||||||
|
|
||||||
auto const &pool { *m_pools[ level ] };
|
|
||||||
::glActiveTexture( GL_TEXTURE0 );
|
|
||||||
::glBindTexture( GL_TEXTURE_2D_ARRAY, pool.heights() );
|
|
||||||
::glActiveTexture( GL_TEXTURE1 );
|
|
||||||
::glBindTexture( GL_TEXTURE_2D_ARRAY, pool.materials() );
|
|
||||||
|
|
||||||
::glUniform1f( m_uniforms.samplestep, gridstep * static_cast<float>( 1u << level ) );
|
|
||||||
::glUniform1i( m_uniforms.side, static_cast<GLint>( pool.side() ) );
|
|
||||||
// the band over which this level turns into the next. the coarsest level has no next
|
|
||||||
// one, so its band lies beyond anything that is ever drawn
|
|
||||||
if( level + 1 < m_reader.levels() ) {
|
|
||||||
::glUniform2f(
|
|
||||||
m_uniforms.morph,
|
|
||||||
static_cast<float>( terrain_morph_begin( level, m_finest, tilesize ) ),
|
|
||||||
static_cast<float>( terrain_morph_end( level, m_finest, tilesize ) ) );
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
::glUniform2f( m_uniforms.morph, 1e30f, 2e30f );
|
|
||||||
}
|
|
||||||
|
|
||||||
m_indices[ level ]->bind( gl::buffer::ELEMENT_ARRAY_BUFFER );
|
|
||||||
::glDrawElementsInstanced(
|
|
||||||
GL_TRIANGLES, static_cast<GLsizei>( m_indexcounts[ level ] ), GL_UNSIGNED_INT, nullptr,
|
|
||||||
static_cast<GLsizei>( count ) );
|
|
||||||
}
|
|
||||||
m_stats.drawn = drawn;
|
|
||||||
|
|
||||||
::glDisable( GL_POLYGON_OFFSET_FILL );
|
::glDisable( GL_POLYGON_OFFSET_FILL );
|
||||||
gl::buffer::unbind( gl::buffer::TEXTURE_BUFFER );
|
::glActiveTexture( GL_TEXTURE1 );
|
||||||
|
::glBindTexture( GL_TEXTURE_2D, 0 );
|
||||||
::glActiveTexture( GL_TEXTURE0 );
|
::glActiveTexture( GL_TEXTURE0 );
|
||||||
|
::glBindTexture( GL_TEXTURE_2D_ARRAY, 0 );
|
||||||
opengl_texture::reset_unit_cache();
|
opengl_texture::reset_unit_cache();
|
||||||
m_vao->unbind();
|
|
||||||
gl::program::bind( 0 );
|
gl::program::bind( 0 );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,37 +11,35 @@ module;
|
|||||||
#include <array>
|
#include <array>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "scene/quantizedmeshreader.h"
|
#include "scene/quantizedmeshreader.h"
|
||||||
|
|
||||||
export module eu07.rendering.terrainclipmap;
|
export module eu07.rendering.terrainclipmap;
|
||||||
import eu07.gl.shader;
|
import eu07.gl.shader;
|
||||||
import eu07.gl.buffer;
|
|
||||||
import eu07.gl.vao;
|
|
||||||
import eu07.glm;
|
import eu07.glm;
|
||||||
import eu07.rendering.terraintileloader;
|
import eu07.rendering.terraintileloader;
|
||||||
import eu07.rendering.terrainstatus;
|
import eu07.rendering.terrainstatus;
|
||||||
import eu07.rendering.terraintilepool_tin;
|
import eu07.rendering.terrainmesharena;
|
||||||
import eu07.rendering.terrainground;
|
import eu07.rendering.terrainground;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
||||||
// Draws TIN (Triangulated Irregular Network) terrain from Quantized Mesh tiles.
|
// Draws a scenery's cooked terrain: a quadtree of mesh tiles, each a piece of the ground as the
|
||||||
|
// scenery drew it, thinned out the further from the camera it is drawn.
|
||||||
//
|
//
|
||||||
// Replaces the old heightfield texture array approach with vertex/index buffers per tile.
|
// Each frame a walk of the quadtree settles on the tiles to draw: a tile is split when the ground
|
||||||
// Each tile is an independent triangulated mesh with multiple LOD levels. The terrain adapts
|
// it stands for would be off by more pixels than the detail setting allows, and only when all four
|
||||||
// density to detail: fine triangulation along track corridors, coarse in open fields.
|
// of its children are on the card - otherwise it is drawn itself and the children are asked for, so
|
||||||
|
// that streaming shows coarse ground rather than a hole. Because every vertex on a tile's border
|
||||||
|
// survives into all of its coarser levels, two neighbours drawn at different levels share their
|
||||||
|
// border exactly, and nothing has to be stitched or morphed.
|
||||||
//
|
//
|
||||||
// CDLOD (Continuous Distance LOD): tiles select their LOD based on distance from camera,
|
// Every resident tile lives in the same pair of buffers, so the whole terrain is drawn in one call.
|
||||||
// with edge stitching to prevent cracks between LOD transitions.
|
|
||||||
//
|
|
||||||
// The class owns GL resources and file handles. Given a viewpoint and visibility test,
|
|
||||||
// it loads, uploads, and renders appropriate tiles each frame.
|
|
||||||
class terrain_clipmap {
|
class terrain_clipmap {
|
||||||
|
|
||||||
public:
|
public:
|
||||||
@@ -53,116 +51,121 @@ public:
|
|||||||
terrain_clipmap( terrain_clipmap const & ) = delete;
|
terrain_clipmap( terrain_clipmap const & ) = delete;
|
||||||
terrain_clipmap &operator=( terrain_clipmap const & ) = delete;
|
terrain_clipmap &operator=( terrain_clipmap const & ) = delete;
|
||||||
|
|
||||||
// opens a cooked TIN directory and builds the gl resources. returns false when the
|
// opens a cooked terrain - an archive or a directory of tiles - and builds the gl resources.
|
||||||
// directory cannot be read, leaving the object unusable but safe to destroy
|
// returns false when it cannot be read, leaving the object unusable but safe to destroy
|
||||||
bool open( std::string const &Path );
|
bool open( std::string const &Path );
|
||||||
bool ready() const { return m_ready; }
|
bool ready() const { return m_ready; }
|
||||||
void close();
|
void close();
|
||||||
|
|
||||||
// asks for the tiles around the viewpoint, uploads a bounded number of those that have
|
// walks the quadtree, asks for what is missing, puts in what has arrived and retires what has
|
||||||
// arrived, and retires the ones that left range. safe to call every frame: the reading
|
// not been wanted for a while. safe to call every frame: the reading happens on the loader's
|
||||||
// and decoding happen on the loader's thread
|
// thread
|
||||||
void update( glm::dvec3 const &Viewpoint );
|
void update( glm::dvec3 const &Viewpoint );
|
||||||
// draws the resident tiles in range that the test says can be seen. the caller is
|
// draws the tiles the walk settled on that the test says can be seen
|
||||||
// expected to have the scene and model uniform blocks already set for a camera-relative
|
|
||||||
// pass. tile positions are passed as uniforms per draw
|
|
||||||
void render( glm::dvec3 const &Viewpoint, visibility const &Visible );
|
void render( glm::dvec3 const &Viewpoint, visibility const &Visible );
|
||||||
|
|
||||||
// how far from the viewpoint terrain is drawn, in metres
|
// how far from the viewpoint terrain is drawn, in metres
|
||||||
void range( float const Range ) { m_range = Range; }
|
void range( float const Range ) { m_range = Range; }
|
||||||
float range() const { return m_range; }
|
float range() const { return m_range; }
|
||||||
// what the screen can resolve: the viewport's pixels per unit of size over distance, and
|
// what the screen can resolve: the viewport's pixels per unit of size over distance, and how
|
||||||
// how many pixels a sample spacing may cover. decides how far each level reaches
|
// many pixels a tile's error may cover before a finer level is used
|
||||||
void detail( double const Pixelsperunit, double const Pixels );
|
void detail( double const Pixelsperunit, double const Pixels );
|
||||||
// the finest level's range the tiles in range were last worked out with
|
// side of a tile at the finest level, in metres. terrains are drawn finest first, so that the
|
||||||
float finest() const { return static_cast<float>( m_finest ); }
|
// depth test keeps the better surface where two of them describe the same ground
|
||||||
|
float gridstep() const { return m_finesttile; }
|
||||||
// metres between samples at the finest level; a scenery's fields are drawn from the
|
// pushes this terrain back in depth by the given rank, so that geometry lying on it wins the
|
||||||
// finest to the coarsest so that the depth test keeps the better surface
|
// depth test, and a coarser terrain loses to a finer one describing the same ground
|
||||||
float gridstep() const { return m_reader.gridstep(); }
|
|
||||||
// pushes this field back in depth by the given rank, so that geometry lying on it wins the
|
|
||||||
// depth test, and a coarser field loses to a finer one describing the same ground. the
|
|
||||||
// offset is in depth, scaled by slope, so it holds at any distance and on any incline
|
|
||||||
void depthrank( unsigned const Rank ) { m_depthrank = Rank; }
|
void depthrank( unsigned const Rank ) { m_depthrank = Rank; }
|
||||||
|
|
||||||
terrain_statistics const &stats() const { return m_stats; }
|
terrain_statistics const &stats() const { return m_stats; }
|
||||||
std::string const &path() const { return m_path; }
|
std::string const &path() const { return m_path; }
|
||||||
// metres per tile side
|
float tilesize() const { return m_finesttile; }
|
||||||
float tilesize() const { return m_tilesize; }
|
std::uint32_t levels() const { return m_levels; }
|
||||||
// number of LOD levels available
|
|
||||||
std::uint32_t levels() const { return m_lodlevels; }
|
|
||||||
std::size_t backlog() const { return m_loader.backlog(); }
|
std::size_t backlog() const { return m_loader.backlog(); }
|
||||||
|
float detailpixels() const { return static_cast<float>( m_detail ); }
|
||||||
|
// side of a tile at each level, and how far that level's ground is off, for the debug panel
|
||||||
|
float leveltile( std::uint32_t const Level ) const;
|
||||||
|
float levelerror( std::uint32_t const Level ) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// one tile resident on the gpu: VBO/IBO handle
|
// one tile held in the buffers
|
||||||
struct resident_tile {
|
struct resident_tile {
|
||||||
std::int32_t x { 0 }, z { 0 };
|
terrain_mesh_arena::piece piece;
|
||||||
|
// for culling, in world space
|
||||||
|
glm::dvec3 centre { 0.0 };
|
||||||
|
float radius { 0.f };
|
||||||
std::uint32_t level { 0 };
|
std::uint32_t level { 0 };
|
||||||
terrain_tile_pool_tin::tile_handle gpu_data;
|
|
||||||
float minheight { 0.f }, maxheight { 0.f };
|
|
||||||
std::uint64_t lastused { 0 };
|
std::uint64_t lastused { 0 };
|
||||||
};
|
};
|
||||||
|
|
||||||
// one tile within range, and the level its distance earns
|
// the tiles the walk settled on, in the order they will be drawn
|
||||||
struct in_range {
|
void walk( glm::dvec3 const &Viewpoint );
|
||||||
std::int64_t key;
|
// asks the loader for every tile the wanted cut of the quadtree is missing, and says whether this
|
||||||
std::uint32_t level;
|
// tile and everything under it is already there
|
||||||
};
|
bool need( quantizedmesh::tile_address const &Tile, glm::dvec3 const &Viewpoint );
|
||||||
|
// whether the level this tile stands for is close enough to the ground below it
|
||||||
// mip level a tile at the given distance is drawn at
|
bool good_enough( quantizedmesh::tile_address const &Tile, double Distance ) const;
|
||||||
std::uint32_t level_for( double const Distance ) const;
|
void choose( quantizedmesh::tile_address const &Tile, glm::dvec3 const &Viewpoint );
|
||||||
// works out which tiles are in range and at what level, and tells the loader what is
|
// where a tile's square is, and how far the viewpoint is from it
|
||||||
// missing. the answer only changes once the camera has moved a fair part of a tile
|
void square( quantizedmesh::tile_address const &Tile, double &West, double &North, double &Side ) const;
|
||||||
void scan( glm::dvec3 const &Viewpoint );
|
double distance_to( quantizedmesh::tile_address const &Tile, glm::dvec3 const &Viewpoint ) const;
|
||||||
// puts a tile the loader has read on the gpu, replacing whatever level it had before
|
bool present( quantizedmesh::tile_address const &Tile ) const;
|
||||||
void upload( terrain_tile_loader::payload const &Tile );
|
void ask_for( quantizedmesh::tile_address const &Tile, double Distance );
|
||||||
void retire( resident_tile const &Tile );
|
void put_in( terrain_tile_loader::payload const &Tile );
|
||||||
|
void retire_unused();
|
||||||
void resolve_uniforms();
|
void resolve_uniforms();
|
||||||
void report_residency();
|
void report_residency();
|
||||||
|
|
||||||
static std::int64_t key( std::int32_t const X, std::int32_t const Z ) {
|
quantizedmesh::reader m_reader; // the table and which tiles there are; reading is the loader's
|
||||||
return ( static_cast<std::int64_t>( X ) << 32 ) ^ static_cast<std::uint32_t>( Z );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tile management
|
|
||||||
quantizedmesh::reader m_reader;
|
|
||||||
terrain_tile_loader m_loader;
|
terrain_tile_loader m_loader;
|
||||||
terrain_tile_pool_tin m_pool;
|
terrain_mesh_arena m_arena;
|
||||||
|
|
||||||
std::vector<terrain_tile_loader::request> m_wanted;
|
|
||||||
std::vector<terrain_tile_loader::payload> m_arrived;
|
|
||||||
// what the last scan found in range, and the level wanted for each tile
|
|
||||||
std::vector<in_range> m_inrange;
|
|
||||||
std::unordered_map<std::int64_t, std::uint32_t> m_wantedlevel;
|
|
||||||
glm::dvec3 m_scanpoint { 0.0 };
|
|
||||||
float m_scanrange { -1.f };
|
|
||||||
double m_finestwanted { 0.0 };
|
|
||||||
double m_finest { 0.0 };
|
|
||||||
|
|
||||||
// Resident tiles (key = tile_key(x,z))
|
|
||||||
std::unordered_map<std::int64_t, resident_tile> m_tiles;
|
|
||||||
|
|
||||||
// Ground texture array (materials)
|
|
||||||
terrain_ground m_ground;
|
terrain_ground m_ground;
|
||||||
|
|
||||||
// Shader and uniforms
|
quantizedmesh::terrain_table m_table;
|
||||||
|
glm::dvec3 m_origin { 0.0 }; // what the vertices in the buffers are measured from
|
||||||
|
float m_finesttile { 256.f };
|
||||||
|
std::uint32_t m_levels { 1 };
|
||||||
|
|
||||||
|
std::unordered_map<std::int64_t, resident_tile> m_tiles;
|
||||||
|
std::vector<std::int64_t> m_chosen;
|
||||||
|
std::vector<std::pair<double, quantizedmesh::tile_address>> m_wishes;
|
||||||
|
std::vector<quantizedmesh::tile_address> m_wanted;
|
||||||
|
std::vector<terrain_tile_loader::payload> m_arrived;
|
||||||
|
std::unordered_set<std::int64_t> m_asked; // what the last walk asked the loader for
|
||||||
|
// whether a subtree could be drawn, worked out once per walk
|
||||||
|
std::unordered_map<std::int64_t, bool> m_readiness;
|
||||||
|
|
||||||
|
// what the draw hands to gl, kept between frames so that a frame allocates nothing
|
||||||
|
std::vector<std::int32_t> m_counts;
|
||||||
|
std::vector<void const *> m_offsets;
|
||||||
|
std::vector<std::int32_t> m_bases;
|
||||||
|
|
||||||
|
glm::dvec3 m_walkpoint { 0.0 };
|
||||||
|
bool m_walked { false };
|
||||||
|
double m_pixelsperunit { 0.0 };
|
||||||
|
double m_detail { 3.0 };
|
||||||
|
double m_walkdetail { 0.0 };
|
||||||
|
float m_range { 6000.f };
|
||||||
|
float m_walkrange { -1.f };
|
||||||
|
|
||||||
std::optional<gl::program> m_shader;
|
std::optional<gl::program> m_shader;
|
||||||
struct uniform_locations {
|
struct uniform_locations {
|
||||||
int tile_offset { -1 }; // vec3: tile origin relative to camera
|
int origin { -1 }; // where the terrain's origin is, relative to the camera
|
||||||
int ground { -1 }; // sampler2DArray: material textures
|
int world { -1 }; // and where it is in the world
|
||||||
|
int ground { -1 };
|
||||||
|
int materials { -1 };
|
||||||
} m_uniforms;
|
} m_uniforms;
|
||||||
|
|
||||||
// Configuration
|
|
||||||
float m_tilesize { 256.0f };
|
|
||||||
std::uint32_t m_lodlevels { 4 };
|
|
||||||
terrain_statistics m_stats;
|
terrain_statistics m_stats;
|
||||||
std::uint64_t m_frame { 0 };
|
std::uint64_t m_frame { 0 };
|
||||||
std::size_t m_reportedresidency { 0 };
|
std::size_t m_reportedresidency { 0 };
|
||||||
|
std::size_t m_reporteddrawn { 0 };
|
||||||
|
std::uint64_t m_reportedframe { 0 };
|
||||||
bool m_reported { false };
|
bool m_reported { false };
|
||||||
float m_range { 6000.f };
|
|
||||||
unsigned m_depthrank { 0 };
|
unsigned m_depthrank { 0 };
|
||||||
std::size_t m_budget { 2048 }; // tiles allowed resident
|
// what the buffers may hold before tiles nobody asked for lately are let go
|
||||||
std::size_t m_uploadsperframe { 32 };
|
std::size_t m_budget { 192u << 20 };
|
||||||
|
std::size_t m_putsperframe { 16 };
|
||||||
bool m_ready { false };
|
bool m_ready { false };
|
||||||
std::string m_path;
|
std::string m_path;
|
||||||
};
|
};
|
||||||
|
|||||||
255
rendering/terrainmesharena.cpp
Normal file
255
rendering/terrainmesharena.cpp
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/*
|
||||||
|
This Source Code Form is subject to the
|
||||||
|
terms of the Mozilla Public License, v.
|
||||||
|
2.0. If a copy of the MPL was not
|
||||||
|
distributed with this file, You can
|
||||||
|
obtain one at
|
||||||
|
http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
module;
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <map>
|
||||||
|
#include <vector>
|
||||||
|
#include <glad/glad.h>
|
||||||
|
#include "scene/quantizedmeshreader.h"
|
||||||
|
|
||||||
|
module eu07.rendering.terrainmesharena;
|
||||||
|
import eu07.utilities.logs;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// what the buffers start at, in vertices and indices, and the most they may reach. a vertex is 24
|
||||||
|
// bytes and an index 4, so the ceiling is about 400 MB of terrain on the card
|
||||||
|
constexpr std::uint32_t firstvertices { 1u << 19 };
|
||||||
|
constexpr std::uint32_t firstindices { 1u << 21 };
|
||||||
|
constexpr std::uint32_t mostvertices { 1u << 24 };
|
||||||
|
constexpr std::uint32_t mostindices { 1u << 26 };
|
||||||
|
|
||||||
|
} // anonymous namespace
|
||||||
|
|
||||||
|
void
|
||||||
|
terrain_mesh_arena::span_list::give( std::uint32_t const At, std::uint32_t const Count ) {
|
||||||
|
|
||||||
|
if( Count == 0 ) { return; }
|
||||||
|
auto placed { m_free.emplace( At, Count ).first };
|
||||||
|
// join what now touches, so that the room a tile gave back can hold a larger one
|
||||||
|
auto after { std::next( placed ) };
|
||||||
|
if( ( after != m_free.end() ) && ( placed->first + placed->second == after->first ) ) {
|
||||||
|
placed->second += after->second;
|
||||||
|
m_free.erase( after );
|
||||||
|
}
|
||||||
|
if( placed != m_free.begin() ) {
|
||||||
|
auto before { std::prev( placed ) };
|
||||||
|
if( before->first + before->second == placed->first ) {
|
||||||
|
before->second += placed->second;
|
||||||
|
m_free.erase( placed );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool
|
||||||
|
terrain_mesh_arena::span_list::take( std::uint32_t const Count, std::uint32_t &At ) {
|
||||||
|
|
||||||
|
for( auto span = m_free.begin(); span != m_free.end(); ++span ) {
|
||||||
|
if( span->second < Count ) { continue; }
|
||||||
|
At = span->first;
|
||||||
|
if( span->second == Count ) { m_free.erase( span ); }
|
||||||
|
else {
|
||||||
|
auto const left { span->second - Count };
|
||||||
|
m_free.erase( span );
|
||||||
|
m_free.emplace( At + Count, left );
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
terrain_mesh_arena::~terrain_mesh_arena() {
|
||||||
|
|
||||||
|
destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool
|
||||||
|
terrain_mesh_arena::create() {
|
||||||
|
|
||||||
|
destroy();
|
||||||
|
::glGenVertexArrays( 1, &m_vao );
|
||||||
|
::glGenBuffers( 1, &m_vertexbuffer );
|
||||||
|
::glGenBuffers( 1, &m_indexbuffer );
|
||||||
|
if( ( m_vao == 0 ) || ( m_vertexbuffer == 0 ) || ( m_indexbuffer == 0 ) ) { destroy(); return false; }
|
||||||
|
|
||||||
|
::glBindVertexArray( m_vao );
|
||||||
|
::glBindBuffer( GL_ARRAY_BUFFER, m_vertexbuffer );
|
||||||
|
::glBufferData( GL_ARRAY_BUFFER,
|
||||||
|
static_cast<GLsizeiptr>( firstvertices ) * sizeof( quantizedmesh::render_vertex ), nullptr, GL_DYNAMIC_DRAW );
|
||||||
|
::glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, m_indexbuffer );
|
||||||
|
::glBufferData( GL_ELEMENT_ARRAY_BUFFER,
|
||||||
|
static_cast<GLsizeiptr>( firstindices ) * sizeof( std::uint32_t ), nullptr, GL_DYNAMIC_DRAW );
|
||||||
|
|
||||||
|
auto const stride { static_cast<GLsizei>( sizeof( quantizedmesh::render_vertex ) ) };
|
||||||
|
::glEnableVertexAttribArray( 0 );
|
||||||
|
::glVertexAttribPointer( 0, 3, GL_FLOAT, GL_FALSE, stride,
|
||||||
|
reinterpret_cast<void const *>( offsetof( quantizedmesh::render_vertex, x ) ) );
|
||||||
|
::glEnableVertexAttribArray( 1 );
|
||||||
|
::glVertexAttribIPointer( 1, 1, GL_UNSIGNED_SHORT, stride,
|
||||||
|
reinterpret_cast<void const *>( offsetof( quantizedmesh::render_vertex, material ) ) );
|
||||||
|
::glBindVertexArray( 0 );
|
||||||
|
::glBindBuffer( GL_ARRAY_BUFFER, 0 );
|
||||||
|
::glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, 0 );
|
||||||
|
|
||||||
|
m_vertexroom = firstvertices;
|
||||||
|
m_indexroom = firstindices;
|
||||||
|
m_freevertices.clear();
|
||||||
|
m_freeindices.clear();
|
||||||
|
m_freevertices.give( 0, m_vertexroom );
|
||||||
|
m_freeindices.give( 0, m_indexroom );
|
||||||
|
m_verticesheld = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
terrain_mesh_arena::destroy() {
|
||||||
|
|
||||||
|
if( m_vao != 0 ) { ::glDeleteVertexArrays( 1, &m_vao ); m_vao = 0; }
|
||||||
|
if( m_vertexbuffer != 0 ) { ::glDeleteBuffers( 1, &m_vertexbuffer ); m_vertexbuffer = 0; }
|
||||||
|
if( m_indexbuffer != 0 ) { ::glDeleteBuffers( 1, &m_indexbuffer ); m_indexbuffer = 0; }
|
||||||
|
m_vertexroom = m_indexroom = 0;
|
||||||
|
m_freevertices.clear();
|
||||||
|
m_freeindices.clear();
|
||||||
|
m_verticesheld = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Growing copies what is there into a larger buffer on the card: the pieces keep their places, so
|
||||||
|
// nothing has to be read back or re-uploaded.
|
||||||
|
bool
|
||||||
|
terrain_mesh_arena::grow_vertices( std::uint32_t const Wanted ) {
|
||||||
|
|
||||||
|
auto room { m_vertexroom };
|
||||||
|
while( ( room < m_vertexroom + Wanted ) && ( room < mostvertices ) ) { room *= 2; }
|
||||||
|
if( room <= m_vertexroom ) { return false; }
|
||||||
|
|
||||||
|
std::uint32_t grown { 0 };
|
||||||
|
::glGenBuffers( 1, &grown );
|
||||||
|
if( grown == 0 ) { return false; }
|
||||||
|
::glBindBuffer( GL_ARRAY_BUFFER, grown );
|
||||||
|
::glBufferData( GL_ARRAY_BUFFER,
|
||||||
|
static_cast<GLsizeiptr>( room ) * sizeof( quantizedmesh::render_vertex ), nullptr, GL_DYNAMIC_DRAW );
|
||||||
|
::glBindBuffer( GL_COPY_READ_BUFFER, m_vertexbuffer );
|
||||||
|
::glBindBuffer( GL_COPY_WRITE_BUFFER, grown );
|
||||||
|
::glCopyBufferSubData( GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, 0, 0,
|
||||||
|
static_cast<GLsizeiptr>( m_vertexroom ) * sizeof( quantizedmesh::render_vertex ) );
|
||||||
|
::glBindBuffer( GL_COPY_READ_BUFFER, 0 );
|
||||||
|
::glBindBuffer( GL_COPY_WRITE_BUFFER, 0 );
|
||||||
|
::glDeleteBuffers( 1, &m_vertexbuffer );
|
||||||
|
m_vertexbuffer = grown;
|
||||||
|
|
||||||
|
::glBindVertexArray( m_vao );
|
||||||
|
::glBindBuffer( GL_ARRAY_BUFFER, m_vertexbuffer );
|
||||||
|
auto const stride { static_cast<GLsizei>( sizeof( quantizedmesh::render_vertex ) ) };
|
||||||
|
::glVertexAttribPointer( 0, 3, GL_FLOAT, GL_FALSE, stride,
|
||||||
|
reinterpret_cast<void const *>( offsetof( quantizedmesh::render_vertex, x ) ) );
|
||||||
|
::glVertexAttribIPointer( 1, 1, GL_UNSIGNED_SHORT, stride,
|
||||||
|
reinterpret_cast<void const *>( offsetof( quantizedmesh::render_vertex, material ) ) );
|
||||||
|
::glBindVertexArray( 0 );
|
||||||
|
::glBindBuffer( GL_ARRAY_BUFFER, 0 );
|
||||||
|
|
||||||
|
m_freevertices.give( m_vertexroom, room - m_vertexroom );
|
||||||
|
m_vertexroom = room;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool
|
||||||
|
terrain_mesh_arena::grow_indices( std::uint32_t const Wanted ) {
|
||||||
|
|
||||||
|
auto room { m_indexroom };
|
||||||
|
while( ( room < m_indexroom + Wanted ) && ( room < mostindices ) ) { room *= 2; }
|
||||||
|
if( room <= m_indexroom ) { return false; }
|
||||||
|
|
||||||
|
std::uint32_t grown { 0 };
|
||||||
|
::glGenBuffers( 1, &grown );
|
||||||
|
if( grown == 0 ) { return false; }
|
||||||
|
::glBindBuffer( GL_ARRAY_BUFFER, grown );
|
||||||
|
::glBufferData( GL_ARRAY_BUFFER,
|
||||||
|
static_cast<GLsizeiptr>( room ) * sizeof( std::uint32_t ), nullptr, GL_DYNAMIC_DRAW );
|
||||||
|
::glBindBuffer( GL_COPY_READ_BUFFER, m_indexbuffer );
|
||||||
|
::glBindBuffer( GL_COPY_WRITE_BUFFER, grown );
|
||||||
|
::glCopyBufferSubData( GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, 0, 0,
|
||||||
|
static_cast<GLsizeiptr>( m_indexroom ) * sizeof( std::uint32_t ) );
|
||||||
|
::glBindBuffer( GL_COPY_READ_BUFFER, 0 );
|
||||||
|
::glBindBuffer( GL_COPY_WRITE_BUFFER, 0 );
|
||||||
|
::glDeleteBuffers( 1, &m_indexbuffer );
|
||||||
|
m_indexbuffer = grown;
|
||||||
|
|
||||||
|
::glBindVertexArray( m_vao );
|
||||||
|
::glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, m_indexbuffer );
|
||||||
|
::glBindVertexArray( 0 );
|
||||||
|
::glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, 0 );
|
||||||
|
|
||||||
|
m_freeindices.give( m_indexroom, room - m_indexroom );
|
||||||
|
m_indexroom = room;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
terrain_mesh_arena::piece
|
||||||
|
terrain_mesh_arena::put( quantizedmesh::reader::tile_data const &Tile ) {
|
||||||
|
|
||||||
|
piece where;
|
||||||
|
if( ( m_vao == 0 ) || ( true == Tile.vertices.empty() ) || ( true == Tile.indices.empty() ) ) {
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
auto const vertices { static_cast<std::uint32_t>( Tile.vertices.size() ) };
|
||||||
|
auto const indices { static_cast<std::uint32_t>( Tile.indices.size() ) };
|
||||||
|
|
||||||
|
std::uint32_t atvertex { 0 };
|
||||||
|
if( false == m_freevertices.take( vertices, atvertex ) ) {
|
||||||
|
if( false == grow_vertices( vertices ) ) { return where; }
|
||||||
|
if( false == m_freevertices.take( vertices, atvertex ) ) { return where; }
|
||||||
|
}
|
||||||
|
std::uint32_t atindex { 0 };
|
||||||
|
if( false == m_freeindices.take( indices, atindex ) ) {
|
||||||
|
if( false == grow_indices( indices ) ) {
|
||||||
|
m_freevertices.give( atvertex, vertices );
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
if( false == m_freeindices.take( indices, atindex ) ) {
|
||||||
|
m_freevertices.give( atvertex, vertices );
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::glBindBuffer( GL_ARRAY_BUFFER, m_vertexbuffer );
|
||||||
|
::glBufferSubData( GL_ARRAY_BUFFER,
|
||||||
|
static_cast<GLintptr>( atvertex ) * sizeof( quantizedmesh::render_vertex ),
|
||||||
|
static_cast<GLsizeiptr>( vertices ) * sizeof( quantizedmesh::render_vertex ),
|
||||||
|
Tile.vertices.data() );
|
||||||
|
::glBindBuffer( GL_ARRAY_BUFFER, 0 );
|
||||||
|
|
||||||
|
// the indices are stored as the tile had them, counted from its own first vertex; where the
|
||||||
|
// tile sits in the buffer is given to the draw as its base vertex
|
||||||
|
::glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, m_indexbuffer );
|
||||||
|
::glBufferSubData( GL_ELEMENT_ARRAY_BUFFER,
|
||||||
|
static_cast<GLintptr>( atindex ) * sizeof( std::uint32_t ),
|
||||||
|
static_cast<GLsizeiptr>( indices ) * sizeof( std::uint32_t ),
|
||||||
|
Tile.indices.data() );
|
||||||
|
::glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, 0 );
|
||||||
|
|
||||||
|
where.firstvertex = atvertex;
|
||||||
|
where.vertices = vertices;
|
||||||
|
where.firstindex = atindex;
|
||||||
|
where.indices = indices;
|
||||||
|
m_verticesheld += vertices;
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
terrain_mesh_arena::take( piece &Piece ) {
|
||||||
|
|
||||||
|
if( false == Piece.held() ) { return; }
|
||||||
|
m_freevertices.give( Piece.firstvertex, Piece.vertices );
|
||||||
|
m_freeindices.give( Piece.firstindex, Piece.indices );
|
||||||
|
m_verticesheld -= Piece.vertices;
|
||||||
|
Piece = piece {};
|
||||||
|
}
|
||||||
87
rendering/terrainmesharena.cppm
Normal file
87
rendering/terrainmesharena.cppm
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
This Source Code Form is subject to the
|
||||||
|
terms of the Mozilla Public License, v.
|
||||||
|
2.0. If a copy of the MPL was not
|
||||||
|
distributed with this file, You can
|
||||||
|
obtain one at
|
||||||
|
http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
module;
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <map>
|
||||||
|
#include <vector>
|
||||||
|
#include "scene/quantizedmeshreader.h"
|
||||||
|
|
||||||
|
export module eu07.rendering.terrainmesharena;
|
||||||
|
|
||||||
|
export {
|
||||||
|
|
||||||
|
// One vertex buffer and one index buffer for the whole terrain.
|
||||||
|
//
|
||||||
|
// Tiles are put in and taken out as the camera moves, so each buffer is handed out in pieces and
|
||||||
|
// the pieces are given back. What this buys is the drawing: every resident tile lives in the same
|
||||||
|
// two buffers behind the same vertex array, so a frame is one glMultiDrawElements rather than a
|
||||||
|
// bind and a draw per tile. Vertices are measured from one point for the whole terrain, which is
|
||||||
|
// what makes that possible.
|
||||||
|
//
|
||||||
|
// The buffers grow when a tile will not fit and nothing can be freed; they never shrink.
|
||||||
|
class terrain_mesh_arena {
|
||||||
|
|
||||||
|
public:
|
||||||
|
// where a tile sits in the two buffers
|
||||||
|
struct piece {
|
||||||
|
std::uint32_t firstvertex { 0 };
|
||||||
|
std::uint32_t vertices { 0 };
|
||||||
|
std::uint32_t firstindex { 0 };
|
||||||
|
std::uint32_t indices { 0 };
|
||||||
|
bool held() const { return indices != 0; }
|
||||||
|
};
|
||||||
|
|
||||||
|
terrain_mesh_arena() = default;
|
||||||
|
~terrain_mesh_arena();
|
||||||
|
terrain_mesh_arena( terrain_mesh_arena const & ) = delete;
|
||||||
|
terrain_mesh_arena &operator=( terrain_mesh_arena const & ) = delete;
|
||||||
|
|
||||||
|
bool create();
|
||||||
|
void destroy();
|
||||||
|
|
||||||
|
// puts a tile's mesh in. returns a piece that is not held when it did not fit
|
||||||
|
piece put( quantizedmesh::reader::tile_data const &Tile );
|
||||||
|
void take( piece &Piece );
|
||||||
|
|
||||||
|
// bound for drawing; the index buffer goes with it
|
||||||
|
std::uint32_t vertexarray() const { return m_vao; }
|
||||||
|
|
||||||
|
std::size_t vertexbytes() const { return m_vertexroom * sizeof( quantizedmesh::render_vertex ); }
|
||||||
|
std::size_t indexbytes() const { return m_indexroom * sizeof( std::uint32_t ); }
|
||||||
|
std::size_t bytes() const { return vertexbytes() + indexbytes(); }
|
||||||
|
std::size_t verticesheld() const { return m_verticesheld; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
// a run of free slots in one of the buffers, kept in order so that neighbours can be joined
|
||||||
|
class span_list {
|
||||||
|
public:
|
||||||
|
void clear() { m_free.clear(); }
|
||||||
|
void give( std::uint32_t At, std::uint32_t Count );
|
||||||
|
// the first run large enough, or the count when there is none
|
||||||
|
bool take( std::uint32_t Count, std::uint32_t &At );
|
||||||
|
private:
|
||||||
|
std::map<std::uint32_t, std::uint32_t> m_free; // where to how many
|
||||||
|
};
|
||||||
|
|
||||||
|
bool grow_vertices( std::uint32_t Wanted );
|
||||||
|
bool grow_indices( std::uint32_t Wanted );
|
||||||
|
|
||||||
|
std::uint32_t m_vao { 0 };
|
||||||
|
std::uint32_t m_vertexbuffer { 0 };
|
||||||
|
std::uint32_t m_indexbuffer { 0 };
|
||||||
|
std::uint32_t m_vertexroom { 0 };
|
||||||
|
std::uint32_t m_indexroom { 0 };
|
||||||
|
span_list m_freevertices;
|
||||||
|
span_list m_freeindices;
|
||||||
|
std::size_t m_verticesheld { 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
} // export
|
||||||
@@ -20,76 +20,50 @@ export module eu07.rendering.terrainstatus;
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
||||||
// Which level a tile is drawn at follows from what its samples look like on screen: a level is
|
// Which level of the quadtree a tile is drawn at follows from what the coarser mesh would cost in
|
||||||
// used out to the distance where one of its sample spacings covers the detail setting in
|
// pixels. Every tile says how far its ground departs from the finest mesh, in metres; that error,
|
||||||
// pixels. Each level doubles the spacing, so each doubles that distance too. Pixelsperunit is
|
// at the tile's distance, covers a number of pixels, and while it covers fewer than the detail
|
||||||
// the viewport height over twice the tangent of half the vertical field of view - what a
|
// setting asks for there is nothing to gain by going finer. Pixelsperunit is the viewport height
|
||||||
// metre at a metre's distance spans - so zooming in or a taller window pushes detail further.
|
// over twice the tangent of half the vertical field of view - what a metre at a metre's distance
|
||||||
|
// spans - so zooming in or a taller window pushes detail further out on its own.
|
||||||
//
|
//
|
||||||
// The finest range never drops below four tile sides, whatever the screen: the morphing below
|
// This is the format's own rule, and the reason a tile carries its error at all.
|
||||||
// only closes the seams when a level's range is several tiles long.
|
inline constexpr std::size_t terrain_maxlevels { 16 };
|
||||||
inline constexpr double terrain_finest_tiles { 4.0 };
|
|
||||||
|
|
||||||
inline double
|
inline bool
|
||||||
terrain_finest_range( double const Gridstep, double const Tilesize, double const Pixelsperunit, double const Detail ) {
|
terrain_level_enough(
|
||||||
return std::max( terrain_finest_tiles * Tilesize, Gridstep * Pixelsperunit / std::max( 0.1, Detail ) );
|
double const Error, double const Distance, double const Pixelsperunit, double const Detail ) {
|
||||||
|
return Error * Pixelsperunit <= std::max( 0.1, Detail ) * std::max( 1.0, Distance );
|
||||||
}
|
}
|
||||||
|
|
||||||
// how far the camera may move before the tiles in range and their levels are worked out
|
// how far the camera may move before which tiles are wanted is worked out again, as a share of the
|
||||||
// again, in tile sides
|
// finest tile's side
|
||||||
inline constexpr double terrain_rescan_tiles { 0.25 };
|
inline constexpr double terrain_rescan_tiles { 0.25 };
|
||||||
|
|
||||||
// the distance up to which a level is used, given the finest level's range
|
// what one terrain's streamer is doing, kept by it and read by the debug panel
|
||||||
inline double
|
|
||||||
terrain_level_distance( std::uint32_t const Level, double const Finest ) {
|
|
||||||
return Finest * std::pow( 2.0, Level );
|
|
||||||
}
|
|
||||||
|
|
||||||
// A tile morphs into the next level over a band at the far end of its level's range. Two
|
|
||||||
// neighbours at levels L and L+1 then meet exactly when, all along their shared edge, the
|
|
||||||
// finer tile has finished morphing and the coarser one has not started. The band ends a
|
|
||||||
// rescan's worth of travel short of the range, because levels are chosen where the camera
|
|
||||||
// was at the last rescan while the morph follows the camera every frame; and it is a fifth
|
|
||||||
// of the range wide, which keeps the coarser tile's band, a whole range further out, clear of
|
|
||||||
// any edge a finer tile can reach. Both were settled by a geometric test over random camera
|
|
||||||
// positions, three tile sizes and several finest ranges (tools/terraincook/seamcheck.py),
|
|
||||||
// which finds no gap with these values and finds them at every level boundary with a band
|
|
||||||
// ending at the range itself.
|
|
||||||
inline constexpr double terrain_morph_band { 0.2 };
|
|
||||||
|
|
||||||
// the distances, from the camera, over which a level L tile morphs into level L+1
|
|
||||||
inline double
|
|
||||||
terrain_morph_end( std::uint32_t const Level, double const Finest, double const Tilesize ) {
|
|
||||||
return terrain_level_distance( Level, Finest ) - terrain_rescan_tiles * Tilesize;
|
|
||||||
}
|
|
||||||
|
|
||||||
inline double
|
|
||||||
terrain_morph_begin( std::uint32_t const Level, double const Finest, double const Tilesize ) {
|
|
||||||
return terrain_morph_end( Level, Finest, Tilesize ) - terrain_morph_band * terrain_level_distance( Level, Finest );
|
|
||||||
}
|
|
||||||
|
|
||||||
// what one terrain field's streamer is doing, kept by the field and read by the debug panel
|
|
||||||
struct terrain_statistics {
|
struct terrain_statistics {
|
||||||
std::size_t resident { 0 }; // tiles holding gl textures
|
std::size_t resident { 0 }; // tiles held in the vertex and index buffers
|
||||||
std::size_t inview { 0 }; // resident tiles within range in the last update
|
std::size_t chosen { 0 }; // tiles the last walk of the quadtree settled on drawing
|
||||||
std::size_t drawn { 0 }; // of those, the ones inside the frustum in the last draw
|
std::size_t drawn { 0 }; // of those, the ones inside the frustum in the last draw
|
||||||
std::size_t wanted { 0 }; // tiles in range missing, or at the wrong level
|
std::size_t triangles { 0 }; // triangles in the last draw
|
||||||
std::size_t uploaded { 0 }; // uploads since opening
|
std::size_t wanted { 0 }; // tiles the walk asked for and did not have
|
||||||
std::size_t dropped { 0 }; // arrivals discarded as stale since opening
|
std::size_t uploaded { 0 }; // tiles put in the buffers since opening
|
||||||
std::size_t texturebytes { 0 };
|
std::size_t dropped { 0 }; // arrivals discarded as no longer wanted
|
||||||
std::array<std::size_t, 8> perlevel {}; // tiles in view at each level
|
std::size_t gpubytes { 0 }; // what the two buffers hold
|
||||||
|
std::array<std::size_t, terrain_maxlevels> perlevel {}; // tiles drawn at each level
|
||||||
};
|
};
|
||||||
|
|
||||||
// one field as the debug panel shows it. the renderer writes it once a frame from the main
|
// one terrain as the debug panel shows it. the renderer writes it once a frame from the main view,
|
||||||
// view, the panel reads it; neither needs to know the other's types
|
// the panel reads it; neither needs to know the other's types
|
||||||
struct terrain_field_status {
|
struct terrain_field_status {
|
||||||
std::string path;
|
std::string path;
|
||||||
float gridstep { 0.f };
|
float tilesize { 0.f }; // side of a tile at the finest level, in metres
|
||||||
float tilesize { 0.f };
|
|
||||||
float range { 0.f };
|
float range { 0.f };
|
||||||
float finest { 0.f }; // range of the finest level, from the screen
|
float detail { 0.f }; // pixels an error may cover before a finer level is used
|
||||||
std::uint32_t levels { 0 };
|
std::uint32_t levels { 0 };
|
||||||
std::size_t backlog { 0 }; // tiles the loader has queued or finished and not handed over
|
std::size_t backlog { 0 }; // tiles the loader has queued or finished and not handed over
|
||||||
|
std::array<float, terrain_maxlevels> leveltile {}; // tile side at each level
|
||||||
|
std::array<float, terrain_maxlevels> levelerror {}; // metres a level departs from the finest
|
||||||
terrain_statistics stats;
|
terrain_statistics stats;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,14 @@ terrain_tile_loader::~terrain_tile_loader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool
|
bool
|
||||||
terrain_tile_loader::open( std::string const &Path ) {
|
terrain_tile_loader::open(
|
||||||
|
std::string const &Path, double const Originx, double const Originy, double const Originz ) {
|
||||||
|
|
||||||
close();
|
close();
|
||||||
if( false == m_reader.open( Path ) ) { return false; }
|
if( false == m_reader.open( Path ) ) { return false; }
|
||||||
|
m_originx = Originx;
|
||||||
|
m_originy = Originy;
|
||||||
|
m_originz = Originz;
|
||||||
m_stop = false;
|
m_stop = false;
|
||||||
m_thread = std::thread( &terrain_tile_loader::work, this );
|
m_thread = std::thread( &terrain_tile_loader::work, this );
|
||||||
return true;
|
return true;
|
||||||
@@ -53,24 +57,22 @@ terrain_tile_loader::close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool
|
bool
|
||||||
terrain_tile_loader::underway( request const &Tile ) const {
|
terrain_tile_loader::underway( quantizedmesh::tile_address const &Tile ) const {
|
||||||
|
|
||||||
if( ( m_running.has_value() ) && ( *m_running == Tile ) ) { return true; }
|
if( ( m_running.has_value() ) && ( *m_running == Tile ) ) { return true; }
|
||||||
return std::any_of(
|
return std::any_of(
|
||||||
m_done.begin(), m_done.end(),
|
m_done.begin(), m_done.end(),
|
||||||
[ &Tile ]( payload const &Done ) { return Done.tile == Tile; } );
|
[ &Tile ]( payload const &Done ) { return Done.data.tile == Tile; } );
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
terrain_tile_loader::want( std::vector<request> const &Requests ) {
|
terrain_tile_loader::want( std::vector<quantizedmesh::tile_address> const &Requests ) {
|
||||||
|
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock( m_mutex );
|
std::lock_guard<std::mutex> lock( m_mutex );
|
||||||
m_pending.clear();
|
m_pending.clear();
|
||||||
for( auto const &tile : Requests ) {
|
for( auto const &tile : Requests ) {
|
||||||
if( false == underway( tile ) ) {
|
if( false == underway( tile ) ) { m_pending.push_back( tile ); }
|
||||||
m_pending.push_back( tile );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m_wake.notify_one();
|
m_wake.notify_one();
|
||||||
@@ -105,7 +107,7 @@ void
|
|||||||
terrain_tile_loader::work() {
|
terrain_tile_loader::work() {
|
||||||
|
|
||||||
while( true ) {
|
while( true ) {
|
||||||
request tile;
|
quantizedmesh::tile_address tile;
|
||||||
{
|
{
|
||||||
std::unique_lock<std::mutex> lock( m_mutex );
|
std::unique_lock<std::mutex> lock( m_mutex );
|
||||||
m_wake.wait( lock, [ this ]() {
|
m_wake.wait( lock, [ this ]() {
|
||||||
@@ -116,40 +118,11 @@ terrain_tile_loader::work() {
|
|||||||
m_running = tile;
|
m_running = tile;
|
||||||
}
|
}
|
||||||
|
|
||||||
// the read itself happens outside the lock, so the render thread can replace the
|
// the read itself happens outside the lock, so the render thread can replace the wish
|
||||||
// wish list while a tile is being decoded
|
// list while a tile is being decoded
|
||||||
payload result;
|
payload result;
|
||||||
result.tile = tile;
|
result.data.tile = tile;
|
||||||
|
result.valid = m_reader.read_tile( tile, result.data, m_originx, m_originy, m_originz );
|
||||||
quantizedmesh::reader::tile_data data;
|
|
||||||
result.valid = m_reader.read_tile( tile.x, tile.z, tile.level, data );
|
|
||||||
|
|
||||||
if( result.valid ) {
|
|
||||||
// Copy data to payload
|
|
||||||
result.positions_x = std::move( data.positions_x );
|
|
||||||
result.positions_y = std::move( data.positions_y );
|
|
||||||
result.positions_z = std::move( data.positions_z );
|
|
||||||
result.uvs_u = std::move( data.uvs_u );
|
|
||||||
result.uvs_v = std::move( data.uvs_v );
|
|
||||||
result.materials = std::move( data.materials );
|
|
||||||
result.indices = std::move( data.indices );
|
|
||||||
|
|
||||||
result.north_edge = std::move( data.north_edge );
|
|
||||||
result.south_edge = std::move( data.south_edge );
|
|
||||||
result.west_edge = std::move( data.west_edge );
|
|
||||||
result.east_edge = std::move( data.east_edge );
|
|
||||||
|
|
||||||
result.center_x = data.center_x;
|
|
||||||
result.center_y = data.center_y;
|
|
||||||
result.center_z = data.center_z;
|
|
||||||
result.min_x = data.min_x;
|
|
||||||
result.min_y = data.min_y;
|
|
||||||
result.min_z = data.min_z;
|
|
||||||
result.max_x = data.max_x;
|
|
||||||
result.max_y = data.max_y;
|
|
||||||
result.max_z = data.max_z;
|
|
||||||
result.geometric_error = data.geometric_error;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock( m_mutex );
|
std::lock_guard<std::mutex> lock( m_mutex );
|
||||||
m_running.reset();
|
m_running.reset();
|
||||||
|
|||||||
@@ -23,49 +23,18 @@ export module eu07.rendering.terraintileloader;
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
||||||
// Reads and decodes TIN (Quantized Mesh) terrain tiles on a thread of its own.
|
// Reads and decodes terrain mesh tiles on a thread of its own.
|
||||||
//
|
//
|
||||||
// Reads .qm tile files, dequantizes vertices, and hands back ready-to-upload vertex/index
|
// Reading a tile means finding it in the archive, unpacking it and turning the quantized vertices
|
||||||
// data. Does the I/O and decompression off the render thread. The renderer uploads at its own
|
// back into something a vertex buffer will take. None of that belongs on the render thread. The
|
||||||
// pace, and the loader prioritizes nearest tiles first.
|
// renderer says which tiles it wants, nearest first, and collects whatever has arrived at its own
|
||||||
//
|
// pace.
|
||||||
// Replaces heightfield tile loading entirely - TIN is the only terrain format now.
|
|
||||||
class terrain_tile_loader {
|
class terrain_tile_loader {
|
||||||
|
|
||||||
public:
|
public:
|
||||||
struct request {
|
|
||||||
std::int32_t x { 0 }, z { 0 };
|
|
||||||
std::uint32_t level { 0 };
|
|
||||||
|
|
||||||
bool operator==( request const & ) const = default;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct payload {
|
struct payload {
|
||||||
request tile;
|
quantizedmesh::reader::tile_data data;
|
||||||
bool valid { false };
|
bool valid { false };
|
||||||
|
|
||||||
// Vertex data (dequantized, ready for GPU)
|
|
||||||
std::vector<double> positions_x;
|
|
||||||
std::vector<double> positions_y;
|
|
||||||
std::vector<double> positions_z;
|
|
||||||
std::vector<float> uvs_u;
|
|
||||||
std::vector<float> uvs_v;
|
|
||||||
std::vector<std::uint8_t> materials;
|
|
||||||
|
|
||||||
// Index data
|
|
||||||
std::vector<std::uint32_t> indices;
|
|
||||||
|
|
||||||
// Edge indices for seamless LOD stitching
|
|
||||||
std::vector<std::uint16_t> north_edge;
|
|
||||||
std::vector<std::uint16_t> south_edge;
|
|
||||||
std::vector<std::uint16_t> west_edge;
|
|
||||||
std::vector<std::uint16_t> east_edge;
|
|
||||||
|
|
||||||
// Tile bounds (for placement)
|
|
||||||
double center_x, center_y, center_z;
|
|
||||||
double min_x, min_y, min_z;
|
|
||||||
double max_x, max_y, max_z;
|
|
||||||
double geometric_error;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
terrain_tile_loader() = default;
|
terrain_tile_loader() = default;
|
||||||
@@ -73,14 +42,15 @@ public:
|
|||||||
terrain_tile_loader( terrain_tile_loader const & ) = delete;
|
terrain_tile_loader( terrain_tile_loader const & ) = delete;
|
||||||
terrain_tile_loader &operator=( terrain_tile_loader const & ) = delete;
|
terrain_tile_loader &operator=( terrain_tile_loader const & ) = delete;
|
||||||
|
|
||||||
// opens the file on the loader's own handle and starts the thread
|
// opens the source on the loader's own handle and starts the thread. Origin is what the
|
||||||
bool open( std::string const &Path );
|
// vertices come back measured from
|
||||||
|
bool open( std::string const &Path, double Originx, double Originy, double Originz );
|
||||||
// stops the thread; anything not collected is dropped
|
// stops the thread; anything not collected is dropped
|
||||||
void close();
|
void close();
|
||||||
|
|
||||||
// replaces the list of wanted tiles, nearest first. tiles being read or already read and
|
// replaces the list of wanted tiles, nearest first. tiles being read or already read and not
|
||||||
// not yet collected are left out of it, so asking again every frame costs nothing
|
// yet collected are left out of it, so asking again every frame costs nothing
|
||||||
void want( std::vector<request> const &Requests );
|
void want( std::vector<quantizedmesh::tile_address> const &Requests );
|
||||||
// moves up to Count finished tiles into Out, returning how many
|
// moves up to Count finished tiles into Out, returning how many
|
||||||
std::size_t collect( std::vector<payload> &Out, std::size_t const Count );
|
std::size_t collect( std::vector<payload> &Out, std::size_t const Count );
|
||||||
// tiles queued or finished and not yet collected
|
// tiles queued or finished and not yet collected
|
||||||
@@ -88,20 +58,21 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
void work();
|
void work();
|
||||||
// whether the tile is being read or waits to be collected. caller holds the lock
|
// whether the tile is being read or waits to be collected. the caller holds the lock
|
||||||
bool underway( request const &Tile ) const;
|
bool underway( quantizedmesh::tile_address const &Tile ) const;
|
||||||
|
|
||||||
quantizedmesh::reader m_reader;
|
quantizedmesh::reader m_reader;
|
||||||
|
double m_originx { 0.0 }, m_originy { 0.0 }, m_originz { 0.0 };
|
||||||
std::thread m_thread;
|
std::thread m_thread;
|
||||||
mutable std::mutex m_mutex;
|
mutable std::mutex m_mutex;
|
||||||
std::condition_variable m_wake;
|
std::condition_variable m_wake;
|
||||||
std::deque<request> m_pending;
|
std::deque<quantizedmesh::tile_address> m_pending;
|
||||||
std::optional<request> m_running;
|
std::optional<quantizedmesh::tile_address> m_running;
|
||||||
std::deque<payload> m_done;
|
std::deque<payload> m_done;
|
||||||
bool m_stop { false };
|
bool m_stop { false };
|
||||||
// finished tiles allowed to wait for collection. past this the thread stops reading:
|
// finished tiles allowed to wait for collection. past this the thread stops reading: the
|
||||||
// the renderer uploads at its own pace, and a backlog read for where the camera was a
|
// renderer uploads at its own pace, and a backlog read for where the camera was a second ago
|
||||||
// second ago is work spent on tiles nobody wants any more
|
// is work spent on tiles nobody wants any more
|
||||||
static constexpr std::size_t donelimit { 32 };
|
static constexpr std::size_t donelimit { 32 };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
/*
|
|
||||||
This Source Code Form is subject to the
|
|
||||||
terms of the Mozilla Public License, v.
|
|
||||||
2.0. If a copy of the MPL was not
|
|
||||||
distributed with this file, You can
|
|
||||||
obtain one at
|
|
||||||
http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
module;
|
|
||||||
#include <algorithm>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <vector>
|
|
||||||
#include <glad/glad.h>
|
|
||||||
|
|
||||||
module eu07.rendering.terraintilepool_tin;
|
|
||||||
import eu07.glm;
|
|
||||||
|
|
||||||
terrain_tile_pool_tin::terrain_tile_pool_tin() = default;
|
|
||||||
|
|
||||||
terrain_tile_pool_tin::~terrain_tile_pool_tin() = default;
|
|
||||||
|
|
||||||
terrain_tile_pool_tin::tile_handle
|
|
||||||
terrain_tile_pool_tin::upload(
|
|
||||||
std::vector<double> const &positions_x,
|
|
||||||
std::vector<double> const &positions_y,
|
|
||||||
std::vector<double> const &positions_z,
|
|
||||||
std::vector<float> const &uvs_u,
|
|
||||||
std::vector<float> const &uvs_v,
|
|
||||||
std::vector<std::uint8_t> const &materials,
|
|
||||||
std::vector<std::uint32_t> const &indices,
|
|
||||||
glm::dvec3 const &origin
|
|
||||||
) {
|
|
||||||
tile_handle handle;
|
|
||||||
handle.origin = origin;
|
|
||||||
|
|
||||||
if( positions_x.empty() || indices.empty() ) {
|
|
||||||
return handle; // Invalid
|
|
||||||
}
|
|
||||||
|
|
||||||
std::size_t const vertex_count = positions_x.size();
|
|
||||||
handle.vertex_count = static_cast<std::uint32_t>( vertex_count );
|
|
||||||
handle.index_count = static_cast<std::uint32_t>( indices.size() );
|
|
||||||
|
|
||||||
// Pack vertices into interleaved format
|
|
||||||
std::vector<vertex> vertices;
|
|
||||||
vertices.reserve( vertex_count );
|
|
||||||
|
|
||||||
for( std::size_t i = 0; i < vertex_count; ++i ) {
|
|
||||||
vertex v;
|
|
||||||
// Store relative to origin for camera-relative rendering
|
|
||||||
v.x = static_cast<float>( positions_x[ i ] - origin.x );
|
|
||||||
v.y = static_cast<float>( positions_y[ i ] - origin.y );
|
|
||||||
v.z = static_cast<float>( positions_z[ i ] - origin.z );
|
|
||||||
v.u = uvs_u[ i ];
|
|
||||||
v.v = uvs_v[ i ];
|
|
||||||
v.material = i < materials.size() ? materials[ i ] : 0;
|
|
||||||
v.pad[0] = v.pad[1] = v.pad[2] = 0;
|
|
||||||
vertices.push_back( v );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create VAO
|
|
||||||
glGenVertexArrays( 1, &handle.vao_id );
|
|
||||||
glBindVertexArray( handle.vao_id );
|
|
||||||
|
|
||||||
// Create and upload VBO
|
|
||||||
glGenBuffers( 1, &handle.vbo_id );
|
|
||||||
glBindBuffer( GL_ARRAY_BUFFER, handle.vbo_id );
|
|
||||||
glBufferData( GL_ARRAY_BUFFER,
|
|
||||||
vertices.size() * sizeof( vertex ),
|
|
||||||
vertices.data(),
|
|
||||||
GL_STATIC_DRAW );
|
|
||||||
|
|
||||||
// Position attribute (location 0)
|
|
||||||
glEnableVertexAttribArray( 0 );
|
|
||||||
glVertexAttribPointer( 0, 3, GL_FLOAT, GL_FALSE, sizeof( vertex ),
|
|
||||||
reinterpret_cast<void *>( offsetof( vertex, x ) ) );
|
|
||||||
|
|
||||||
// UV attribute (location 1)
|
|
||||||
glEnableVertexAttribArray( 1 );
|
|
||||||
glVertexAttribPointer( 1, 2, GL_FLOAT, GL_FALSE, sizeof( vertex ),
|
|
||||||
reinterpret_cast<void *>( offsetof( vertex, u ) ) );
|
|
||||||
|
|
||||||
// Material attribute (location 2)
|
|
||||||
glEnableVertexAttribArray( 2 );
|
|
||||||
glVertexAttribIPointer( 2, 1, GL_UNSIGNED_BYTE, sizeof( vertex ),
|
|
||||||
reinterpret_cast<void *>( offsetof( vertex, material ) ) );
|
|
||||||
|
|
||||||
// Create and upload IBO
|
|
||||||
glGenBuffers( 1, &handle.ibo_id );
|
|
||||||
glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, handle.ibo_id );
|
|
||||||
glBufferData( GL_ELEMENT_ARRAY_BUFFER,
|
|
||||||
indices.size() * sizeof( std::uint32_t ),
|
|
||||||
indices.data(),
|
|
||||||
GL_STATIC_DRAW );
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
glBindVertexArray( 0 );
|
|
||||||
glBindBuffer( GL_ARRAY_BUFFER, 0 );
|
|
||||||
glBindBuffer( GL_ELEMENT_ARRAY_BUFFER, 0 );
|
|
||||||
|
|
||||||
// Track allocation
|
|
||||||
m_allocated_vertices += vertex_count;
|
|
||||||
m_allocated_indices += indices.size();
|
|
||||||
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
void
|
|
||||||
terrain_tile_pool_tin::free( tile_handle &Handle ) {
|
|
||||||
if( !Handle.valid() ) return;
|
|
||||||
|
|
||||||
// Free GL resources
|
|
||||||
if( Handle.vao_id != 0 ) {
|
|
||||||
glDeleteVertexArrays( 1, &Handle.vao_id );
|
|
||||||
}
|
|
||||||
if( Handle.vbo_id != 0 ) {
|
|
||||||
glDeleteBuffers( 1, &Handle.vbo_id );
|
|
||||||
}
|
|
||||||
if( Handle.ibo_id != 0 ) {
|
|
||||||
glDeleteBuffers( 1, &Handle.ibo_id );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
m_allocated_vertices -= Handle.vertex_count;
|
|
||||||
m_allocated_indices -= Handle.index_count;
|
|
||||||
|
|
||||||
// Invalidate handle
|
|
||||||
Handle = tile_handle{};
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/*
|
|
||||||
This Source Code Form is subject to the
|
|
||||||
terms of the Mozilla Public License, v.
|
|
||||||
2.0. If a copy of the MPL was not
|
|
||||||
distributed with this file, You can
|
|
||||||
obtain one at
|
|
||||||
http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
module;
|
|
||||||
#include <cstdint>
|
|
||||||
#include <memory>
|
|
||||||
#include <optional>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
export module eu07.rendering.terraintilepool_tin;
|
|
||||||
import eu07.gl.buffer;
|
|
||||||
import eu07.glm;
|
|
||||||
|
|
||||||
export {
|
|
||||||
|
|
||||||
// GPU storage for TIN terrain tiles - vertex and index buffers.
|
|
||||||
// Replaces texture array approach used by heightfield.
|
|
||||||
//
|
|
||||||
// Each tile gets its own VBO/IBO pair. Tiles are uploaded as they arrive from the loader
|
|
||||||
// and freed when they leave range. Budget is vertex/index count rather than texture memory.
|
|
||||||
class terrain_tile_pool_tin {
|
|
||||||
|
|
||||||
public:
|
|
||||||
// Vertex format for terrain
|
|
||||||
struct vertex {
|
|
||||||
float x, y, z; // Position (relative to tile origin)
|
|
||||||
float u, v; // Texture coordinates
|
|
||||||
std::uint8_t material; // Material index
|
|
||||||
std::uint8_t pad[3]; // Alignment to 24 bytes
|
|
||||||
};
|
|
||||||
|
|
||||||
static_assert( sizeof( vertex ) == 24 );
|
|
||||||
|
|
||||||
// Handle to uploaded tile
|
|
||||||
struct tile_handle {
|
|
||||||
std::uint32_t vbo_id { 0 };
|
|
||||||
std::uint32_t ibo_id { 0 };
|
|
||||||
std::uint32_t vao_id { 0 };
|
|
||||||
std::uint32_t vertex_count { 0 };
|
|
||||||
std::uint32_t index_count { 0 };
|
|
||||||
glm::dvec3 origin { 0.0 }; // World origin for camera-relative rendering
|
|
||||||
|
|
||||||
bool valid() const { return vbo_id != 0; }
|
|
||||||
};
|
|
||||||
|
|
||||||
terrain_tile_pool_tin();
|
|
||||||
~terrain_tile_pool_tin();
|
|
||||||
|
|
||||||
terrain_tile_pool_tin( terrain_tile_pool_tin const & ) = delete;
|
|
||||||
terrain_tile_pool_tin &operator=( terrain_tile_pool_tin const & ) = delete;
|
|
||||||
|
|
||||||
// Upload tile data to GPU
|
|
||||||
tile_handle upload(
|
|
||||||
std::vector<double> const &positions_x,
|
|
||||||
std::vector<double> const &positions_y,
|
|
||||||
std::vector<double> const &positions_z,
|
|
||||||
std::vector<float> const &uvs_u,
|
|
||||||
std::vector<float> const &uvs_v,
|
|
||||||
std::vector<std::uint8_t> const &materials,
|
|
||||||
std::vector<std::uint32_t> const &indices,
|
|
||||||
glm::dvec3 const &origin // Tile center for relative positioning
|
|
||||||
);
|
|
||||||
|
|
||||||
// Free tile from GPU
|
|
||||||
void free( tile_handle &Handle );
|
|
||||||
|
|
||||||
// Statistics
|
|
||||||
std::size_t allocated_vertices() const { return m_allocated_vertices; }
|
|
||||||
std::size_t allocated_indices() const { return m_allocated_indices; }
|
|
||||||
std::size_t allocated_bytes() const {
|
|
||||||
return m_allocated_vertices * sizeof( vertex ) + m_allocated_indices * sizeof( std::uint32_t );
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::size_t m_allocated_vertices { 0 };
|
|
||||||
std::size_t m_allocated_indices { 0 };
|
|
||||||
};
|
|
||||||
|
|
||||||
} // export
|
|
||||||
179
scene/pakformat.h
Normal file
179
scene/pakformat.h
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/*
|
||||||
|
This Source Code Form is subject to the
|
||||||
|
terms of the Mozilla Public License, v.
|
||||||
|
2.0. If a copy of the MPL was not
|
||||||
|
distributed with this file, You can
|
||||||
|
obtain one at
|
||||||
|
http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// One file holding a scenery's cooked terrain tiles.
|
||||||
|
//
|
||||||
|
// A terrain is tens of thousands of small files, which is the one thing a filesystem is worst at:
|
||||||
|
// opening them costs more than reading them. They go into an archive instead, each entry
|
||||||
|
// compressed on its own so that a tile can be read without touching the rest, and the index is
|
||||||
|
// written last so that cooking never has to hold the tiles in memory.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <zstd.h>
|
||||||
|
|
||||||
|
namespace pak {
|
||||||
|
|
||||||
|
inline constexpr char magic[ 8 ] { 'E', 'U', '0', '7', 'P', 'A', 'K', '\0' };
|
||||||
|
inline constexpr std::uint32_t version { 2 };
|
||||||
|
inline constexpr std::size_t namelength { 64 };
|
||||||
|
|
||||||
|
#pragma pack( push, 1 )
|
||||||
|
|
||||||
|
struct file_header {
|
||||||
|
char magic[ 8 ];
|
||||||
|
std::uint32_t version;
|
||||||
|
std::uint32_t entrycount;
|
||||||
|
std::uint64_t indexoffset;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct index_entry {
|
||||||
|
char name[ namelength ];
|
||||||
|
std::uint64_t offset;
|
||||||
|
std::uint64_t stored; // bytes in the file
|
||||||
|
std::uint64_t plain; // bytes once decompressed; equal to stored when kept as it is
|
||||||
|
};
|
||||||
|
|
||||||
|
#pragma pack( pop )
|
||||||
|
|
||||||
|
// Writes entries straight through to disk as they are added.
|
||||||
|
class writer {
|
||||||
|
|
||||||
|
public:
|
||||||
|
bool open( std::string const &Path ) {
|
||||||
|
m_file.open( Path, std::ios::binary | std::ios::trunc );
|
||||||
|
if( false == m_file.good() ) { return false; }
|
||||||
|
file_header header {};
|
||||||
|
m_file.write( reinterpret_cast<char const *>( &header ), sizeof( header ) );
|
||||||
|
m_at = sizeof( header );
|
||||||
|
return m_file.good();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool add( std::string const &Name, std::vector<std::uint8_t> const &Data ) {
|
||||||
|
if( ( false == m_file.good() ) || ( Name.size() >= namelength ) ) { return false; }
|
||||||
|
index_entry entry {};
|
||||||
|
std::memcpy( entry.name, Name.c_str(), Name.size() );
|
||||||
|
entry.offset = m_at;
|
||||||
|
entry.plain = Data.size();
|
||||||
|
|
||||||
|
m_packed.resize( ZSTD_compressBound( Data.size() ) );
|
||||||
|
auto const packed {
|
||||||
|
ZSTD_compress( m_packed.data(), m_packed.size(), Data.data(), Data.size(), 3 ) };
|
||||||
|
auto const *bytes { reinterpret_cast<char const *>( Data.data() ) };
|
||||||
|
auto size { Data.size() };
|
||||||
|
if( ( 0 == ZSTD_isError( packed ) ) && ( packed < Data.size() ) ) {
|
||||||
|
bytes = reinterpret_cast<char const *>( m_packed.data() );
|
||||||
|
size = packed;
|
||||||
|
}
|
||||||
|
entry.stored = size;
|
||||||
|
m_file.write( bytes, static_cast<std::streamsize>( size ) );
|
||||||
|
m_at += size;
|
||||||
|
m_index.push_back( entry );
|
||||||
|
return m_file.good();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool close() {
|
||||||
|
if( false == m_file.good() ) { return false; }
|
||||||
|
file_header header {};
|
||||||
|
std::memcpy( header.magic, magic, sizeof( magic ) );
|
||||||
|
header.version = version;
|
||||||
|
header.entrycount = static_cast<std::uint32_t>( m_index.size() );
|
||||||
|
header.indexoffset = m_at;
|
||||||
|
for( auto const &entry : m_index ) {
|
||||||
|
m_file.write( reinterpret_cast<char const *>( &entry ), sizeof( entry ) );
|
||||||
|
}
|
||||||
|
m_file.seekp( 0 );
|
||||||
|
m_file.write( reinterpret_cast<char const *>( &header ), sizeof( header ) );
|
||||||
|
auto const good { m_file.good() };
|
||||||
|
m_file.close();
|
||||||
|
return good;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t entrycount() const { return m_index.size(); }
|
||||||
|
std::uint64_t bytes() const { return m_at + m_index.size() * sizeof( index_entry ); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::ofstream m_file;
|
||||||
|
std::uint64_t m_at { 0 };
|
||||||
|
std::vector<index_entry> m_index;
|
||||||
|
std::vector<std::uint8_t> m_packed;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reads entries back. One reader is used from one thread at a time: it keeps a handle and seeks.
|
||||||
|
class reader {
|
||||||
|
|
||||||
|
public:
|
||||||
|
bool open( std::string const &Path ) {
|
||||||
|
close();
|
||||||
|
m_file.open( Path, std::ios::binary );
|
||||||
|
if( false == m_file.good() ) { return false; }
|
||||||
|
file_header header {};
|
||||||
|
m_file.read( reinterpret_cast<char *>( &header ), sizeof( header ) );
|
||||||
|
if( false == m_file.good() ) { return false; }
|
||||||
|
if( 0 != std::memcmp( header.magic, magic, sizeof( magic ) ) ) { return false; }
|
||||||
|
if( header.version != version ) { return false; }
|
||||||
|
m_file.seekg( static_cast<std::streamoff>( header.indexoffset ) );
|
||||||
|
for( std::uint32_t entry = 0; entry < header.entrycount; ++entry ) {
|
||||||
|
index_entry record {};
|
||||||
|
m_file.read( reinterpret_cast<char *>( &record ), sizeof( record ) );
|
||||||
|
if( false == m_file.good() ) { return false; }
|
||||||
|
record.name[ namelength - 1 ] = '\0';
|
||||||
|
m_index.emplace( record.name, record );
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
m_index.clear();
|
||||||
|
if( true == m_file.is_open() ) { m_file.close(); }
|
||||||
|
m_file.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool exists( std::string const &Name ) const { return m_index.contains( Name ); }
|
||||||
|
std::size_t entrycount() const { return m_index.size(); }
|
||||||
|
|
||||||
|
std::vector<std::string> names() const {
|
||||||
|
std::vector<std::string> names;
|
||||||
|
names.reserve( m_index.size() );
|
||||||
|
for( auto const &[ name, entry ] : m_index ) { names.push_back( name ); }
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool read( std::string const &Name, std::vector<std::uint8_t> &Out ) {
|
||||||
|
auto const found { m_index.find( Name ) };
|
||||||
|
if( found == m_index.end() ) { return false; }
|
||||||
|
auto const &entry { found->second };
|
||||||
|
m_stored.resize( entry.stored );
|
||||||
|
m_file.seekg( static_cast<std::streamoff>( entry.offset ) );
|
||||||
|
m_file.read( reinterpret_cast<char *>( m_stored.data() ), static_cast<std::streamsize>( entry.stored ) );
|
||||||
|
if( false == m_file.good() ) { m_file.clear(); return false; }
|
||||||
|
if( entry.stored == entry.plain ) {
|
||||||
|
Out.swap( m_stored );
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Out.resize( entry.plain );
|
||||||
|
auto const plain {
|
||||||
|
ZSTD_decompress( Out.data(), Out.size(), m_stored.data(), m_stored.size() ) };
|
||||||
|
return ( 0 == ZSTD_isError( plain ) ) && ( plain == entry.plain );
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::ifstream m_file;
|
||||||
|
std::unordered_map<std::string, index_entry> m_index;
|
||||||
|
std::vector<std::uint8_t> m_stored;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pak
|
||||||
981
scene/quantizedmeshcooker.cpp
Normal file
981
scene/quantizedmeshcooker.cpp
Normal file
@@ -0,0 +1,981 @@
|
|||||||
|
/*
|
||||||
|
This Source Code Form is subject to the
|
||||||
|
terms of the Mozilla Public License, v.
|
||||||
|
2.0. If a copy of the MPL was not
|
||||||
|
distributed with this file, You can
|
||||||
|
obtain one at
|
||||||
|
http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "scene/quantizedmeshcooker.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <limits>
|
||||||
|
#include <map>
|
||||||
|
#include <queue>
|
||||||
|
|
||||||
|
namespace quantizedmesh {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// a triangle with less surface than this is nothing at all, whichever way it faces, and is the
|
||||||
|
// only thing the cook refuses to lay. Walls - the sides of an embankment, a cutting, an abutment -
|
||||||
|
// are ground as much as anything else: nothing about a mesh says it has to be a height field, and
|
||||||
|
// leaving them out is what opens holes along every bank
|
||||||
|
constexpr double nullarea { 1e-6 };
|
||||||
|
// vertices nearer than this to each other, to a tile border or to a plane are the same thing.
|
||||||
|
// a tenth of a millimetre is below anything the game can show and far above what the arithmetic
|
||||||
|
// of cutting a triangle can be out by
|
||||||
|
constexpr double weldepsilon { 1e-4 };
|
||||||
|
// the deepest quadtree worth cooking: 4096 tiles along a side of the finest level
|
||||||
|
constexpr std::uint32_t maxlevels { 13 };
|
||||||
|
// The finest tile is this many typical triangle edges across, and never outside these bounds. A tile
|
||||||
|
// has to be large against the triangles in it: a triangle crossing a border is cut, and the pieces
|
||||||
|
// cost vertices neither side had. It also has to stay small enough to be worth loading and culling
|
||||||
|
// on its own, which is what the bounds are for.
|
||||||
|
constexpr double tileedges { 16.0 };
|
||||||
|
constexpr double smallesttileside { 128.0 };
|
||||||
|
constexpr double largesttileside { 1024.0 };
|
||||||
|
// how many edge lengths are kept to find the typical one. the first hundred thousand triangles say
|
||||||
|
// as much about a scenery as all of them
|
||||||
|
constexpr std::size_t edgesample { 100000 };
|
||||||
|
// a tile is never collapsed below this, however far away it is drawn: past a point the triangles
|
||||||
|
// saved are not worth the ground losing its shape
|
||||||
|
constexpr std::size_t smallesttile { 64 };
|
||||||
|
// A level may move the ground by this share of its tile's side, and no further. A level is used from
|
||||||
|
// about twice its tile's side away, where a share this small is well under a pixel; letting it go
|
||||||
|
// further would buy triangles that no distance is far enough to use. Ground already so coarse that a
|
||||||
|
// level cannot be thinned within it simply comes out nearly a copy of the level below, which is the
|
||||||
|
// right answer: there was nothing there to save.
|
||||||
|
constexpr double errorshare { 128.0 };
|
||||||
|
|
||||||
|
// the area of a triangle in space, whichever way it faces
|
||||||
|
double
|
||||||
|
surface_area( cooker::corner const &A, cooker::corner const &B, cooker::corner const &C ) {
|
||||||
|
auto const nx { ( B.y - A.y ) * ( C.z - A.z ) - ( B.z - A.z ) * ( C.y - A.y ) };
|
||||||
|
auto const ny { ( B.z - A.z ) * ( C.x - A.x ) - ( B.x - A.x ) * ( C.z - A.z ) };
|
||||||
|
auto const nz { ( B.x - A.x ) * ( C.y - A.y ) - ( B.y - A.y ) * ( C.x - A.x ) };
|
||||||
|
return 0.5 * std::sqrt( nx * nx + ny * ny + nz * nz );
|
||||||
|
}
|
||||||
|
|
||||||
|
// a triangle standing upright has no extent in the plan at all. it still belongs to the ground,
|
||||||
|
// but it has to be told apart: the tile it goes to cannot be decided by cutting it in two
|
||||||
|
double
|
||||||
|
planar_extent( cooker::corner const &A, cooker::corner const &B, cooker::corner const &C ) {
|
||||||
|
return std::max(
|
||||||
|
std::max( { A.x, B.x, C.x } ) - std::min( { A.x, B.x, C.x } ),
|
||||||
|
std::max( { A.z, B.z, C.z } ) - std::min( { A.z, B.z, C.z } ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// The error of collapsing a vertex away, as Garland and Heckbert measure it: the sum of the
|
||||||
|
// squared distances to the planes of the triangles that met there. Kept as the ten distinct
|
||||||
|
// entries of the symmetric matrix.
|
||||||
|
struct quadric {
|
||||||
|
double m[ 10 ] {};
|
||||||
|
|
||||||
|
void add_plane( double const A, double const B, double const C, double const D, double const Weight ) {
|
||||||
|
m[ 0 ] += Weight * A * A; m[ 1 ] += Weight * A * B; m[ 2 ] += Weight * A * C; m[ 3 ] += Weight * A * D;
|
||||||
|
m[ 4 ] += Weight * B * B; m[ 5 ] += Weight * B * C; m[ 6 ] += Weight * B * D;
|
||||||
|
m[ 7 ] += Weight * C * C; m[ 8 ] += Weight * C * D;
|
||||||
|
m[ 9 ] += Weight * D * D;
|
||||||
|
}
|
||||||
|
|
||||||
|
void operator+=( quadric const &Other ) {
|
||||||
|
for( std::size_t index = 0; index < 10; ++index ) { m[ index ] += Other.m[ index ]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
double at( double const X, double const Y, double const Z ) const {
|
||||||
|
return m[ 0 ] * X * X + 2.0 * m[ 1 ] * X * Y + 2.0 * m[ 2 ] * X * Z + 2.0 * m[ 3 ] * X
|
||||||
|
+ m[ 4 ] * Y * Y + 2.0 * m[ 5 ] * Y * Z + 2.0 * m[ 6 ] * Y
|
||||||
|
+ m[ 7 ] * Z * Z + 2.0 * m[ 8 ] * Z
|
||||||
|
+ m[ 9 ];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// which sides of a tile's square a point lies on, one bit each
|
||||||
|
std::uint8_t
|
||||||
|
sides_of( double const X, double const Z, double const West, double const North, double const Side ) {
|
||||||
|
std::uint8_t sides { 0 };
|
||||||
|
if( std::abs( X - West ) < weldepsilon ) { sides |= 1; }
|
||||||
|
if( std::abs( X - ( West + Side ) ) < weldepsilon ) { sides |= 2; }
|
||||||
|
if( std::abs( Z - North ) < weldepsilon ) { sides |= 4; }
|
||||||
|
if( std::abs( Z - ( North + Side ) ) < weldepsilon ){ sides |= 8; }
|
||||||
|
return sides;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a vertex of a polygon being cut along the tile grid
|
||||||
|
struct clipvertex {
|
||||||
|
double x, y, z;
|
||||||
|
};
|
||||||
|
|
||||||
|
clipvertex
|
||||||
|
between( clipvertex const &From, clipvertex const &To, double const Share ) {
|
||||||
|
return {
|
||||||
|
From.x + ( To.x - From.x ) * Share,
|
||||||
|
From.y + ( To.y - From.y ) * Share,
|
||||||
|
From.z + ( To.z - From.z ) * Share };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sutherland and Hodgman against one side of the tile's square. Axis 0 is x and 1 is z; the
|
||||||
|
// coordinate cut against is set to the bound itself rather than to what the division gives, so
|
||||||
|
// that the two tiles sharing that border end up with a vertex in exactly the same place.
|
||||||
|
void
|
||||||
|
clip_side( std::vector<clipvertex> &Polygon, int const Axis, double const Bound, bool const Keepabove ) {
|
||||||
|
|
||||||
|
if( Polygon.empty() ) { return; }
|
||||||
|
|
||||||
|
auto const coordinate = [ Axis ]( clipvertex const &Vertex ) {
|
||||||
|
return ( Axis == 0 ) ? Vertex.x : Vertex.z; };
|
||||||
|
auto const inside = [ & ]( clipvertex const &Vertex ) {
|
||||||
|
return Keepabove ? ( coordinate( Vertex ) >= Bound - weldepsilon )
|
||||||
|
: ( coordinate( Vertex ) <= Bound + weldepsilon ); };
|
||||||
|
|
||||||
|
std::vector<clipvertex> kept;
|
||||||
|
kept.reserve( Polygon.size() + 2 );
|
||||||
|
for( std::size_t index = 0; index < Polygon.size(); ++index ) {
|
||||||
|
auto const &here { Polygon[ index ] };
|
||||||
|
auto const &next { Polygon[ ( index + 1 ) % Polygon.size() ] };
|
||||||
|
auto const hereinside { inside( here ) };
|
||||||
|
if( true == hereinside ) { kept.push_back( here ); }
|
||||||
|
if( hereinside == inside( next ) ) { continue; }
|
||||||
|
auto const span { coordinate( next ) - coordinate( here ) };
|
||||||
|
if( std::abs( span ) < weldepsilon ) { continue; }
|
||||||
|
auto crossing { between( here, next, ( Bound - coordinate( here ) ) / span ) };
|
||||||
|
if( Axis == 0 ) { crossing.x = Bound; } else { crossing.z = Bound; }
|
||||||
|
kept.push_back( crossing );
|
||||||
|
}
|
||||||
|
Polygon.swap( kept );
|
||||||
|
}
|
||||||
|
|
||||||
|
} // anonymous namespace
|
||||||
|
|
||||||
|
cooker::~cooker() {
|
||||||
|
|
||||||
|
if( true == m_scratch.is_open() ) { m_scratch.close(); }
|
||||||
|
if( false == m_scratchpath.empty() ) {
|
||||||
|
std::error_code error;
|
||||||
|
std::filesystem::remove( m_scratchpath, error );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool
|
||||||
|
cooker::begin( std::string const &Scratchpath, std::size_t const Tilebudget ) {
|
||||||
|
|
||||||
|
m_scratchpath = Scratchpath;
|
||||||
|
m_tilebudget = std::max<std::size_t>( 256, Tilebudget );
|
||||||
|
m_scratch.open( m_scratchpath, std::ios::binary | std::ios::in | std::ios::out | std::ios::trunc );
|
||||||
|
return m_scratch.good();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint16_t
|
||||||
|
cooker::material_index( std::string_view const Material ) {
|
||||||
|
|
||||||
|
auto const found { m_materiallookup.find( std::string { Material } ) };
|
||||||
|
if( found != m_materiallookup.end() ) { return found->second; }
|
||||||
|
auto const index { static_cast<std::uint16_t>( m_materials.size() ) };
|
||||||
|
m_materials.emplace_back( Material );
|
||||||
|
m_materiallookup.emplace( std::string { Material }, index );
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The scenery's texture coordinates are not kept: ground textures tile in world space, so a
|
||||||
|
// coordinate follows from where a vertex is. What has to be kept is the scale the scenery drew the
|
||||||
|
// material at, and that is measured here - the ratio of world distance to texture distance along
|
||||||
|
// a triangle's edges. Edges too short, or with no texture movement along them, say nothing.
|
||||||
|
void
|
||||||
|
cooker::measure_repeat( std::uint16_t const Material, corner const ( &Corners )[ 3 ] ) {
|
||||||
|
|
||||||
|
if( Material >= m_repeatcounts.size() ) {
|
||||||
|
m_repeatsums.resize( Material + 1, 0.0 );
|
||||||
|
m_repeatsquares.resize( Material + 1, 0.0 );
|
||||||
|
m_repeatcounts.resize( Material + 1, 0 );
|
||||||
|
}
|
||||||
|
for( std::size_t index = 0; index < 3; ++index ) {
|
||||||
|
auto const &from { Corners[ index ] };
|
||||||
|
auto const &to { Corners[ ( index + 1 ) % 3 ] };
|
||||||
|
auto const world {
|
||||||
|
std::sqrt( ( to.x - from.x ) * ( to.x - from.x ) + ( to.z - from.z ) * ( to.z - from.z ) ) };
|
||||||
|
auto const texture {
|
||||||
|
std::sqrt( ( to.u - from.u ) * ( to.u - from.u ) + ( to.v - from.v ) * ( to.v - from.v ) ) };
|
||||||
|
if( ( world < 0.5 ) || ( texture < 1e-4 ) ) { continue; }
|
||||||
|
auto const ratio { world / texture };
|
||||||
|
m_repeatsums[ Material ] += ratio;
|
||||||
|
m_repeatsquares[ Material ] += ratio * ratio;
|
||||||
|
++m_repeatcounts[ Material ];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
cooker::add( corner const &A, corner const &B, corner const &C, std::string_view const Material ) {
|
||||||
|
|
||||||
|
auto const id { static_cast<std::uint32_t>( m_added ) };
|
||||||
|
++m_added;
|
||||||
|
|
||||||
|
if( ( surface_area( A, B, C ) < nullarea ) || ( planar_extent( A, B, C ) < weldepsilon ) ) {
|
||||||
|
// no surface, or standing on a single point in the plan: nothing to lay, and the scenery
|
||||||
|
// goes on drawing it itself
|
||||||
|
m_refusals.push_back( id );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
corner const corners[ 3 ] { A, B, C };
|
||||||
|
if( m_edges.size() < edgesample ) {
|
||||||
|
for( std::size_t index = 0; index < 3; ++index ) {
|
||||||
|
auto const &from { corners[ index ] };
|
||||||
|
auto const &to { corners[ ( index + 1 ) % 3 ] };
|
||||||
|
auto const length {
|
||||||
|
std::sqrt( ( to.x - from.x ) * ( to.x - from.x ) + ( to.z - from.z ) * ( to.z - from.z ) ) };
|
||||||
|
if( length > 0.0 ) { m_edges.push_back( length ); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spilled record {};
|
||||||
|
record.material = material_index( Material );
|
||||||
|
measure_repeat( record.material, corners );
|
||||||
|
for( std::size_t index = 0; index < 3; ++index ) {
|
||||||
|
record.corners[ index * 3 + 0 ] = corners[ index ].x;
|
||||||
|
record.corners[ index * 3 + 1 ] = corners[ index ].y;
|
||||||
|
record.corners[ index * 3 + 2 ] = corners[ index ].z;
|
||||||
|
}
|
||||||
|
m_scratch.write( reinterpret_cast<char const *>( &record ), sizeof( record ) );
|
||||||
|
|
||||||
|
for( auto const &vertex : corners ) {
|
||||||
|
if( false == m_anything ) {
|
||||||
|
m_anything = true;
|
||||||
|
m_minx = m_maxx = vertex.x;
|
||||||
|
m_minz = m_maxz = vertex.z;
|
||||||
|
m_lowest = m_highest = vertex.y;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
m_minx = std::min( m_minx, vertex.x ); m_maxx = std::max( m_maxx, vertex.x );
|
||||||
|
m_minz = std::min( m_minz, vertex.z ); m_maxz = std::max( m_maxz, vertex.z );
|
||||||
|
m_lowest = std::min( m_lowest, vertex.y ); m_highest = std::max( m_highest, vertex.y );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The root square starts on a multiple of the finest tile side and doubles until it holds
|
||||||
|
// everything, so that where a tile's borders fall follows from the terrain alone and a second
|
||||||
|
// cook of the same scenery puts them in the same place.
|
||||||
|
void
|
||||||
|
cooker::measure() {
|
||||||
|
|
||||||
|
// the middle edge length of the sample, and the tile that suits it, rounded to a power of two so
|
||||||
|
// that the same scenery always comes out the same way
|
||||||
|
if( false == m_edges.empty() ) {
|
||||||
|
auto const middle { m_edges.begin() + m_edges.size() / 2 };
|
||||||
|
std::nth_element( m_edges.begin(), middle, m_edges.end() );
|
||||||
|
auto const wanted { std::clamp( tileedges * *middle, smallesttileside, largesttileside ) };
|
||||||
|
m_finesttile = std::exp2( std::round( std::log2( wanted ) ) );
|
||||||
|
m_finesttile = std::clamp( m_finesttile, smallesttileside, largesttileside );
|
||||||
|
m_edges.clear();
|
||||||
|
m_edges.shrink_to_fit();
|
||||||
|
}
|
||||||
|
|
||||||
|
m_west = std::floor( m_minx / m_finesttile ) * m_finesttile;
|
||||||
|
m_north = std::floor( m_minz / m_finesttile ) * m_finesttile;
|
||||||
|
auto const needed { std::max( m_maxx - m_west, m_maxz - m_north ) };
|
||||||
|
m_side = m_finesttile;
|
||||||
|
m_levels = 1;
|
||||||
|
while( ( m_side < needed ) && ( m_levels < maxlevels ) ) {
|
||||||
|
m_side *= 2.0;
|
||||||
|
++m_levels;
|
||||||
|
}
|
||||||
|
// a height range of zero would make every height quantize to nothing
|
||||||
|
if( m_highest - m_lowest < 1.0 ) { m_highest = m_lowest + 1.0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
cooker::square( tile_address const &Tile, double &West, double &North, double &Side ) const {
|
||||||
|
|
||||||
|
Side = m_side / static_cast<double>( 1u << Tile.level );
|
||||||
|
West = m_west + Tile.x * Side;
|
||||||
|
North = m_north + Tile.z * Side;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool
|
||||||
|
cooker::finish( sink const &Sink, terrain_table &Table ) {
|
||||||
|
|
||||||
|
std::sort( m_refusals.begin(), m_refusals.end() );
|
||||||
|
m_report.refused = m_refusals.size();
|
||||||
|
|
||||||
|
if( ( false == m_anything ) || ( false == m_scratch.good() ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
m_scratch.flush();
|
||||||
|
measure();
|
||||||
|
|
||||||
|
// which of the spilled triangles touch which tile of the finest level. a triangle can span
|
||||||
|
// several, and each of them cuts its own piece out of it
|
||||||
|
auto const finest { m_levels - 1 };
|
||||||
|
auto const acrossfinest { static_cast<std::int32_t>( 1u << finest ) };
|
||||||
|
auto const records { m_scratch.tellp() / static_cast<std::streamoff>( sizeof( spilled ) ) };
|
||||||
|
m_scratch.seekg( 0 );
|
||||||
|
for( std::streamoff index = 0; index < records; ++index ) {
|
||||||
|
spilled record {};
|
||||||
|
m_scratch.read( reinterpret_cast<char *>( &record ), sizeof( record ) );
|
||||||
|
if( false == m_scratch.good() ) { return false; }
|
||||||
|
auto minx { record.corners[ 0 ] }, maxx { minx };
|
||||||
|
auto minz { record.corners[ 2 ] }, maxz { minz };
|
||||||
|
for( std::size_t at = 0; at < 3; ++at ) {
|
||||||
|
minx = std::min( minx, record.corners[ at * 3 + 0 ] ); maxx = std::max( maxx, record.corners[ at * 3 + 0 ] );
|
||||||
|
minz = std::min( minz, record.corners[ at * 3 + 2 ] ); maxz = std::max( maxz, record.corners[ at * 3 + 2 ] );
|
||||||
|
}
|
||||||
|
auto const first = [ this ]( double const Value, double const Origin ) {
|
||||||
|
return static_cast<std::int32_t>( std::floor( ( Value - Origin ) / m_finesttile ) ); };
|
||||||
|
auto const fromx { std::clamp( first( minx, m_west ), 0, acrossfinest - 1 ) };
|
||||||
|
auto const tox { std::clamp( first( maxx, m_west ), 0, acrossfinest - 1 ) };
|
||||||
|
auto const fromz { std::clamp( first( minz, m_north ), 0, acrossfinest - 1 ) };
|
||||||
|
auto const toz { std::clamp( first( maxz, m_north ), 0, acrossfinest - 1 ) };
|
||||||
|
for( auto x = fromx; x <= tox; ++x ) {
|
||||||
|
for( auto z = fromz; z <= toz; ++z ) {
|
||||||
|
m_placement.emplace_back(
|
||||||
|
tile_key( { finest, x, z } ), static_cast<std::uint32_t>( index ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::sort( m_placement.begin(), m_placement.end() );
|
||||||
|
|
||||||
|
m_errors.assign( m_levels, 0.0 );
|
||||||
|
cook( { 0, 0, 0 }, Sink );
|
||||||
|
m_report.levels = m_levels;
|
||||||
|
|
||||||
|
Table.rules = cook_rules;
|
||||||
|
Table.west = m_west;
|
||||||
|
Table.north = m_north;
|
||||||
|
Table.side = m_side;
|
||||||
|
Table.lowest = m_lowest;
|
||||||
|
Table.highest = m_highest;
|
||||||
|
Table.levels = m_levels;
|
||||||
|
Table.errors.assign( m_errors.begin(), m_errors.end() );
|
||||||
|
Table.materials.clear();
|
||||||
|
for( std::size_t index = 0; index < m_materials.size(); ++index ) {
|
||||||
|
// a material the measurement never caught falls back to something plausible rather than
|
||||||
|
// to zero, which would divide by nothing in the shader
|
||||||
|
auto const caught { ( index < m_repeatcounts.size() ) && ( m_repeatcounts[ index ] > 0 ) };
|
||||||
|
auto const repeat {
|
||||||
|
caught ? m_repeatsums[ index ] / static_cast<double>( m_repeatcounts[ index ] ) : 4.0 };
|
||||||
|
Table.materials.push_back( { m_materials[ index ], static_cast<float>( repeat ) } );
|
||||||
|
}
|
||||||
|
|
||||||
|
m_scratch.close();
|
||||||
|
std::error_code error;
|
||||||
|
std::filesystem::remove( m_scratchpath, error );
|
||||||
|
m_scratchpath.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cooker::mesh
|
||||||
|
cooker::cook( tile_address const &Tile, sink const &Sink ) {
|
||||||
|
|
||||||
|
auto result {
|
||||||
|
( Tile.level + 1 == m_levels ) ? lay( Tile ) : merge( Tile, Sink ) };
|
||||||
|
if( true == result.empty() ) { return result; }
|
||||||
|
|
||||||
|
if( Tile.level + 1 < m_levels ) {
|
||||||
|
// The finest level is the ground as the scenery drew it and is left alone. A level above it
|
||||||
|
// holds what four tiles below it held, and is collapsed back to what one of them held: the
|
||||||
|
// cost of a level is then a quarter of the level below, and the whole pyramid costs a third
|
||||||
|
// more than the finest alone. A count fixed in advance cannot do that - a tile that never
|
||||||
|
// reaches it would be copied rather than simplified - so it only serves as a ceiling.
|
||||||
|
double west { 0.0 }, north { 0.0 }, side { 0.0 };
|
||||||
|
square( Tile, west, north, side );
|
||||||
|
auto const merged { result.indices.size() / 3 };
|
||||||
|
simplify( result, std::clamp( merged / 4, smallesttile, m_tilebudget ), side / errorshare );
|
||||||
|
}
|
||||||
|
|
||||||
|
m_errors[ Tile.level ] = std::max( m_errors[ Tile.level ], result.error );
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> bytes;
|
||||||
|
encode( Tile, result, bytes );
|
||||||
|
Sink( Tile, bytes );
|
||||||
|
|
||||||
|
++m_report.tiles;
|
||||||
|
m_report.triangles += result.indices.size() / 3;
|
||||||
|
m_report.vertices += result.vertices.size();
|
||||||
|
m_report.bytes += bytes.size();
|
||||||
|
if( ( false == m_quiet ) && ( m_report.tiles % 200 == 0 ) ) {
|
||||||
|
std::printf( "\rCooking terrain: %zu tiles", m_report.tiles );
|
||||||
|
std::fflush( stdout );
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Welds vertices that land in the same place and belong to the same material. Everything the cut
|
||||||
|
// produces is either a vertex of the source or a point on a tile border worked out the same way
|
||||||
|
// from both sides, so the tolerance only has to cover the last bits of the arithmetic.
|
||||||
|
class welder {
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit welder( std::vector<cooker::meshvertex> &Vertices ) : m_vertices( Vertices ) {}
|
||||||
|
|
||||||
|
std::uint32_t operator()( cooker::meshvertex const &Vertex ) {
|
||||||
|
auto const key { make_key( Vertex ) };
|
||||||
|
auto const found { m_known.find( key ) };
|
||||||
|
if( found != m_known.end() ) { return found->second; }
|
||||||
|
auto const index { static_cast<std::uint32_t>( m_vertices.size() ) };
|
||||||
|
m_vertices.push_back( Vertex );
|
||||||
|
m_known.emplace( key, index );
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct key {
|
||||||
|
std::int64_t x, y, z;
|
||||||
|
std::uint16_t material;
|
||||||
|
bool operator==( key const & ) const = default;
|
||||||
|
};
|
||||||
|
struct keyhash {
|
||||||
|
std::size_t operator()( key const &Key ) const noexcept {
|
||||||
|
std::size_t hash { 1469598103934665603ull };
|
||||||
|
auto const mix = [ &hash ]( std::int64_t const Value ) {
|
||||||
|
hash ^= static_cast<std::size_t>( Value );
|
||||||
|
hash *= 1099511628211ull; };
|
||||||
|
mix( Key.x ); mix( Key.y ); mix( Key.z );
|
||||||
|
mix( Key.material );
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
static key make_key( cooker::meshvertex const &Vertex ) {
|
||||||
|
auto const grid = []( double const Value ) {
|
||||||
|
return static_cast<std::int64_t>( std::llround( Value / weldepsilon ) ); };
|
||||||
|
return { grid( Vertex.x ), grid( Vertex.y ), grid( Vertex.z ), Vertex.material };
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<cooker::meshvertex> &m_vertices;
|
||||||
|
std::unordered_map<key, std::uint32_t, keyhash> m_known;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // anonymous namespace
|
||||||
|
|
||||||
|
cooker::mesh
|
||||||
|
cooker::lay( tile_address const &Tile ) {
|
||||||
|
|
||||||
|
mesh result;
|
||||||
|
double west { 0.0 }, north { 0.0 }, side { 0.0 };
|
||||||
|
square( Tile, west, north, side );
|
||||||
|
|
||||||
|
auto const wanted { tile_key( Tile ) };
|
||||||
|
auto const from {
|
||||||
|
std::lower_bound( m_placement.begin(), m_placement.end(),
|
||||||
|
std::pair<std::int64_t, std::uint32_t> { wanted, 0 } ) };
|
||||||
|
auto const to {
|
||||||
|
std::upper_bound( m_placement.begin(), m_placement.end(),
|
||||||
|
std::pair<std::int64_t, std::uint32_t> { wanted, std::numeric_limits<std::uint32_t>::max() } ) };
|
||||||
|
if( from == to ) { return result; }
|
||||||
|
|
||||||
|
welder weld { result.vertices };
|
||||||
|
std::vector<clipvertex> polygon;
|
||||||
|
for( auto entry = from; entry != to; ++entry ) {
|
||||||
|
|
||||||
|
m_scratch.seekg( static_cast<std::streamoff>( entry->second ) * sizeof( spilled ) );
|
||||||
|
spilled record {};
|
||||||
|
m_scratch.read( reinterpret_cast<char *>( &record ), sizeof( record ) );
|
||||||
|
if( false == m_scratch.good() ) { m_scratch.clear(); continue; }
|
||||||
|
|
||||||
|
polygon.clear();
|
||||||
|
for( std::size_t at = 0; at < 3; ++at ) {
|
||||||
|
polygon.push_back(
|
||||||
|
{ record.corners[ at * 3 + 0 ], record.corners[ at * 3 + 1 ], record.corners[ at * 3 + 2 ] } );
|
||||||
|
}
|
||||||
|
clip_side( polygon, 0, west, true );
|
||||||
|
clip_side( polygon, 0, west + side, false );
|
||||||
|
clip_side( polygon, 1, north, true );
|
||||||
|
clip_side( polygon, 1, north + side, false );
|
||||||
|
if( polygon.size() < 3 ) { continue; }
|
||||||
|
|
||||||
|
// A wall lying along a tile border is inside both tiles: the cut keeps it whole on either
|
||||||
|
// side, since it has nothing to cut. Whichever border it sits on, the tile past it is the
|
||||||
|
// one that keeps it, so that it is laid exactly once.
|
||||||
|
{
|
||||||
|
auto onborderonly { false };
|
||||||
|
for( int axis = 0; axis < 2; ++axis ) {
|
||||||
|
auto const at = [ axis ]( clipvertex const &Vertex ) { return axis == 0 ? Vertex.x : Vertex.z; };
|
||||||
|
auto low { at( polygon.front() ) }, high { low };
|
||||||
|
for( auto const &vertex : polygon ) {
|
||||||
|
low = std::min( low, at( vertex ) );
|
||||||
|
high = std::max( high, at( vertex ) );
|
||||||
|
}
|
||||||
|
auto const upper { ( axis == 0 ? west + side : north + side ) };
|
||||||
|
if( ( high - low < weldepsilon ) && ( std::abs( low - upper ) < weldepsilon ) ) {
|
||||||
|
onborderonly = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if( true == onborderonly ) { continue; }
|
||||||
|
}
|
||||||
|
|
||||||
|
auto const place = [ & ]( clipvertex const &Vertex ) {
|
||||||
|
return weld( { Vertex.x, Vertex.y, Vertex.z, record.material,
|
||||||
|
sides_of( Vertex.x, Vertex.z, west, north, side ) } ); };
|
||||||
|
|
||||||
|
auto const first { place( polygon[ 0 ] ) };
|
||||||
|
for( std::size_t corner = 1; corner + 1 < polygon.size(); ++corner ) {
|
||||||
|
// the cut keeps the source winding, so fanning the polygon keeps it too
|
||||||
|
auto const second { place( polygon[ corner ] ) };
|
||||||
|
auto const third { place( polygon[ corner + 1 ] ) };
|
||||||
|
if( ( first == second ) || ( second == third ) || ( third == first ) ) { continue; }
|
||||||
|
result.indices.push_back( first );
|
||||||
|
result.indices.push_back( second );
|
||||||
|
result.indices.push_back( third );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
cooker::mesh
|
||||||
|
cooker::merge( tile_address const &Tile, sink const &Sink ) {
|
||||||
|
|
||||||
|
mesh result;
|
||||||
|
double west { 0.0 }, north { 0.0 }, side { 0.0 };
|
||||||
|
square( Tile, west, north, side );
|
||||||
|
|
||||||
|
welder weld { result.vertices };
|
||||||
|
for( std::int32_t stepz = 0; stepz < 2; ++stepz ) {
|
||||||
|
for( std::int32_t stepx = 0; stepx < 2; ++stepx ) {
|
||||||
|
|
||||||
|
auto const child {
|
||||||
|
cook( { Tile.level + 1, Tile.x * 2 + stepx, Tile.z * 2 + stepz }, Sink ) };
|
||||||
|
if( true == child.empty() ) { continue; }
|
||||||
|
result.error = std::max( result.error, child.error );
|
||||||
|
|
||||||
|
// the borders between the four are no longer borders: only the outer edge of this
|
||||||
|
// tile has to stay where it is
|
||||||
|
std::vector<std::uint32_t> here;
|
||||||
|
here.reserve( child.vertices.size() );
|
||||||
|
for( auto vertex : child.vertices ) {
|
||||||
|
vertex.sides = sides_of( vertex.x, vertex.z, west, north, side );
|
||||||
|
here.push_back( weld( vertex ) );
|
||||||
|
}
|
||||||
|
for( auto const index : child.indices ) {
|
||||||
|
result.indices.push_back( here[ index ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge collapse, cheapest first. A vertex is only ever collapsed onto one of its neighbours -
|
||||||
|
// never onto a new point between them - so a vertex that has to stay put, because a neighbouring
|
||||||
|
// tile shares it, simply is never the one that goes. That is the whole of the crack handling:
|
||||||
|
// levels meet because the border is literally the same set of vertices in both.
|
||||||
|
void
|
||||||
|
cooker::simplify( mesh &Mesh, std::size_t const Budget, double const Allowed ) {
|
||||||
|
|
||||||
|
auto triangles { Mesh.indices.size() / 3 };
|
||||||
|
if( triangles <= Budget ) { return; }
|
||||||
|
|
||||||
|
auto &vertices { Mesh.vertices };
|
||||||
|
|
||||||
|
// The quadrics are built and evaluated about the middle of the mesh rather than about the
|
||||||
|
// scenery's origin. A plane's own constant is minus the normal times a point on it, so in world
|
||||||
|
// coordinates it runs to tens or hundreds of thousands, and the quadric's value - a small
|
||||||
|
// distance squared - comes out as the difference of two such numbers. There is no precision left
|
||||||
|
// in that. Measured from the middle of the tile the constants are the size of the tile.
|
||||||
|
std::vector<std::array<double, 3>> local( vertices.size() );
|
||||||
|
{
|
||||||
|
std::array<double, 3> middle { 0.0, 0.0, 0.0 };
|
||||||
|
for( auto const &vertex : vertices ) {
|
||||||
|
middle[ 0 ] += vertex.x; middle[ 1 ] += vertex.y; middle[ 2 ] += vertex.z;
|
||||||
|
}
|
||||||
|
for( auto &value : middle ) { value /= static_cast<double>( vertices.size() ); }
|
||||||
|
for( std::size_t index = 0; index < vertices.size(); ++index ) {
|
||||||
|
local[ index ] = {
|
||||||
|
vertices[ index ].x - middle[ 0 ],
|
||||||
|
vertices[ index ].y - middle[ 1 ],
|
||||||
|
vertices[ index ].z - middle[ 2 ] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<quadric> quadrics( vertices.size() );
|
||||||
|
// the worst the ground has been moved at each vertex by a collapse into it, in metres. what a
|
||||||
|
// level is off by against the level below it; merging four tiles already carried their own
|
||||||
|
// figures up, so the levels are not counted twice
|
||||||
|
std::vector<double> moved( vertices.size(), 0.0 );
|
||||||
|
std::vector<std::vector<std::uint32_t>> incident( vertices.size() );
|
||||||
|
std::vector<bool> gone( triangles, false );
|
||||||
|
std::vector<std::uint32_t> version( vertices.size(), 0 );
|
||||||
|
std::vector<bool> removed( vertices.size(), false );
|
||||||
|
|
||||||
|
auto const cornerof = [ &Mesh ]( std::size_t const Triangle, std::size_t const Corner ) -> std::uint32_t & {
|
||||||
|
return Mesh.indices[ Triangle * 3 + Corner ]; };
|
||||||
|
|
||||||
|
for( std::size_t triangle = 0; triangle < triangles; ++triangle ) {
|
||||||
|
auto const &a { local[ cornerof( triangle, 0 ) ] };
|
||||||
|
auto const &b { local[ cornerof( triangle, 1 ) ] };
|
||||||
|
auto const &c { local[ cornerof( triangle, 2 ) ] };
|
||||||
|
double nx { ( b[ 1 ] - a[ 1 ] ) * ( c[ 2 ] - a[ 2 ] ) - ( b[ 2 ] - a[ 2 ] ) * ( c[ 1 ] - a[ 1 ] ) };
|
||||||
|
double ny { ( b[ 2 ] - a[ 2 ] ) * ( c[ 0 ] - a[ 0 ] ) - ( b[ 0 ] - a[ 0 ] ) * ( c[ 2 ] - a[ 2 ] ) };
|
||||||
|
double nz { ( b[ 0 ] - a[ 0 ] ) * ( c[ 1 ] - a[ 1 ] ) - ( b[ 1 ] - a[ 1 ] ) * ( c[ 0 ] - a[ 0 ] ) };
|
||||||
|
auto const length { std::sqrt( nx * nx + ny * ny + nz * nz ) };
|
||||||
|
if( length < 1e-12 ) { continue; }
|
||||||
|
auto const area { 0.5 * length };
|
||||||
|
nx /= length; ny /= length; nz /= length;
|
||||||
|
auto const offset { -( nx * a[ 0 ] + ny * a[ 1 ] + nz * a[ 2 ] ) };
|
||||||
|
for( std::size_t corner = 0; corner < 3; ++corner ) {
|
||||||
|
auto const index { cornerof( triangle, corner ) };
|
||||||
|
quadrics[ index ].add_plane( nx, ny, nz, offset, area );
|
||||||
|
incident[ index ].push_back( static_cast<std::uint32_t>( triangle ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the ground ends - the outline of a hole the scenery left, or the edge of the terrain -
|
||||||
|
// only one triangle meets the edge. Nothing in the quadrics above holds such an edge in
|
||||||
|
// place, so a plane standing on it, weighted heavily, is added to both its ends: the outline
|
||||||
|
// then survives into the coarse levels instead of being eaten away.
|
||||||
|
{
|
||||||
|
std::map<std::pair<std::uint32_t, std::uint32_t>, std::size_t> uses;
|
||||||
|
for( std::size_t triangle = 0; triangle < triangles; ++triangle ) {
|
||||||
|
for( std::size_t corner = 0; corner < 3; ++corner ) {
|
||||||
|
auto const from { cornerof( triangle, corner ) };
|
||||||
|
auto const to { cornerof( triangle, ( corner + 1 ) % 3 ) };
|
||||||
|
++uses[ { std::min( from, to ), std::max( from, to ) } ];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for( std::size_t triangle = 0; triangle < triangles; ++triangle ) {
|
||||||
|
auto const &a { local[ cornerof( triangle, 0 ) ] };
|
||||||
|
auto const &b { local[ cornerof( triangle, 1 ) ] };
|
||||||
|
auto const &c { local[ cornerof( triangle, 2 ) ] };
|
||||||
|
double nx { ( b[ 1 ] - a[ 1 ] ) * ( c[ 2 ] - a[ 2 ] ) - ( b[ 2 ] - a[ 2 ] ) * ( c[ 1 ] - a[ 1 ] ) };
|
||||||
|
double ny { ( b[ 2 ] - a[ 2 ] ) * ( c[ 0 ] - a[ 0 ] ) - ( b[ 0 ] - a[ 0 ] ) * ( c[ 2 ] - a[ 2 ] ) };
|
||||||
|
double nz { ( b[ 0 ] - a[ 0 ] ) * ( c[ 1 ] - a[ 1 ] ) - ( b[ 1 ] - a[ 1 ] ) * ( c[ 0 ] - a[ 0 ] ) };
|
||||||
|
auto const facelength { std::sqrt( nx * nx + ny * ny + nz * nz ) };
|
||||||
|
if( facelength < 1e-12 ) { continue; }
|
||||||
|
nx /= facelength; ny /= facelength; nz /= facelength;
|
||||||
|
for( std::size_t corner = 0; corner < 3; ++corner ) {
|
||||||
|
auto const from { cornerof( triangle, corner ) };
|
||||||
|
auto const to { cornerof( triangle, ( corner + 1 ) % 3 ) };
|
||||||
|
auto const use { uses.find( { std::min( from, to ), std::max( from, to ) } ) };
|
||||||
|
if( ( use == uses.end() ) || ( use->second != 1 ) ) { continue; }
|
||||||
|
auto const &head { local[ from ] };
|
||||||
|
auto const &tail { local[ to ] };
|
||||||
|
double ex { tail[ 0 ] - head[ 0 ] }, ey { tail[ 1 ] - head[ 1 ] }, ez { tail[ 2 ] - head[ 2 ] };
|
||||||
|
auto const edgelength { std::sqrt( ex * ex + ey * ey + ez * ez ) };
|
||||||
|
if( edgelength < 1e-9 ) { continue; }
|
||||||
|
ex /= edgelength; ey /= edgelength; ez /= edgelength;
|
||||||
|
// perpendicular to both the edge and the face it belongs to
|
||||||
|
auto const px { ey * nz - ez * ny };
|
||||||
|
auto const py { ez * nx - ex * nz };
|
||||||
|
auto const pz { ex * ny - ey * nx };
|
||||||
|
auto const offset { -( px * head[ 0 ] + py * head[ 1 ] + pz * head[ 2 ] ) };
|
||||||
|
auto const weight { 100.0 * edgelength * edgelength };
|
||||||
|
quadrics[ from ].add_plane( px, py, pz, offset, weight );
|
||||||
|
quadrics[ to ].add_plane( px, py, pz, offset, weight );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct candidate {
|
||||||
|
double cost;
|
||||||
|
std::uint32_t from, to;
|
||||||
|
std::uint32_t fromversion, toversion;
|
||||||
|
bool operator<( candidate const &Other ) const { return cost > Other.cost; }
|
||||||
|
};
|
||||||
|
std::priority_queue<candidate> queue;
|
||||||
|
|
||||||
|
// the cost of taking From away and letting To stand for both. only what the collapse leaves
|
||||||
|
// behind is measured; where To sits does not change, so its own quadric is already paid for
|
||||||
|
auto const cost_of = [ & ]( std::uint32_t const From, std::uint32_t const To ) {
|
||||||
|
quadric together { quadrics[ From ] };
|
||||||
|
together += quadrics[ To ];
|
||||||
|
return std::max( 0.0, together.at( local[ To ][ 0 ], local[ To ][ 1 ], local[ To ][ 2 ] ) ); };
|
||||||
|
|
||||||
|
// How far the ground moves where From used to be, if From were collapsed into To: the height of
|
||||||
|
// whatever would then lie over that spot, against the height that is there now. Worked out before
|
||||||
|
// the collapse rather than after it, so that a collapse which moves the ground too far is passed
|
||||||
|
// over instead of being taken and regretted. The cost above orders the candidates; this is the
|
||||||
|
// one that says whether a candidate may be taken at all, because it is a distance and the cost is
|
||||||
|
// not.
|
||||||
|
auto const drop_of = [ & ]( std::uint32_t const From, std::uint32_t const To ) {
|
||||||
|
auto const &was { local[ From ] };
|
||||||
|
for( auto const triangle : incident[ From ] ) {
|
||||||
|
if( true == gone[ triangle ] ) { continue; }
|
||||||
|
std::array<std::uint32_t, 3> corners {
|
||||||
|
cornerof( triangle, 0 ), cornerof( triangle, 1 ), cornerof( triangle, 2 ) };
|
||||||
|
// a triangle holding both ends of the edge disappears with it, and covers nothing after
|
||||||
|
if( std::find( corners.begin(), corners.end(), To ) != corners.end() ) { continue; }
|
||||||
|
std::array<std::array<double, 3> const *, 3> after {
|
||||||
|
&local[ corners[ 0 ] ], &local[ corners[ 1 ] ], &local[ corners[ 2 ] ] };
|
||||||
|
for( std::size_t corner = 0; corner < 3; ++corner ) {
|
||||||
|
if( corners[ corner ] == From ) { after[ corner ] = &local[ To ]; }
|
||||||
|
}
|
||||||
|
auto const &a { *after[ 0 ] }; auto const &b { *after[ 1 ] }; auto const &c { *after[ 2 ] };
|
||||||
|
auto const twice {
|
||||||
|
( b[ 0 ] - a[ 0 ] ) * ( c[ 2 ] - a[ 2 ] ) - ( c[ 0 ] - a[ 0 ] ) * ( b[ 2 ] - a[ 2 ] ) };
|
||||||
|
if( std::abs( twice ) < 1e-12 ) { continue; }
|
||||||
|
auto const first {
|
||||||
|
( ( b[ 0 ] - was[ 0 ] ) * ( c[ 2 ] - was[ 2 ] ) - ( c[ 0 ] - was[ 0 ] ) * ( b[ 2 ] - was[ 2 ] ) ) / twice };
|
||||||
|
auto const second {
|
||||||
|
( ( c[ 0 ] - was[ 0 ] ) * ( a[ 2 ] - was[ 2 ] ) - ( a[ 0 ] - was[ 0 ] ) * ( c[ 2 ] - was[ 2 ] ) ) / twice };
|
||||||
|
auto const third { 1.0 - first - second };
|
||||||
|
if( ( first < -1e-9 ) || ( second < -1e-9 ) || ( third < -1e-9 ) ) { continue; }
|
||||||
|
return std::abs( first * a[ 1 ] + second * b[ 1 ] + third * c[ 1 ] - was[ 1 ] );
|
||||||
|
}
|
||||||
|
// nothing would lie over that spot: the edge of the ground, or a wall, whose triangles cover
|
||||||
|
// nothing in the plan at all. what changes there is the height where the vertex went
|
||||||
|
return std::abs( was[ 1 ] - local[ To ][ 1 ] ); };
|
||||||
|
|
||||||
|
auto const allowed = [ & ]( std::uint32_t const From, std::uint32_t const To ) {
|
||||||
|
if( ( true == removed[ From ] ) || ( true == removed[ To ] ) ) { return false; }
|
||||||
|
// a vertex a neighbouring tile shares stays where it is
|
||||||
|
if( true == vertices[ From ].locked() ) { return false; }
|
||||||
|
// and a material boundary stays where it is too, or the ground would change colour by
|
||||||
|
// level
|
||||||
|
if( vertices[ From ].material != vertices[ To ].material ) { return false; }
|
||||||
|
// nothing that turns a triangle over or squashes it flat
|
||||||
|
for( auto const triangle : incident[ From ] ) {
|
||||||
|
if( true == gone[ triangle ] ) { continue; }
|
||||||
|
std::array<std::uint32_t, 3> corners {
|
||||||
|
cornerof( triangle, 0 ), cornerof( triangle, 1 ), cornerof( triangle, 2 ) };
|
||||||
|
if( std::find( corners.begin(), corners.end(), To ) != corners.end() ) {
|
||||||
|
// this triangle goes when the edge does. it may not be the only one holding a
|
||||||
|
// piece of the tile's border up: From is not on a border, so the only edge of it
|
||||||
|
// that can be one is between To and the third corner
|
||||||
|
for( auto const third : corners ) {
|
||||||
|
if( ( third == From ) || ( third == To ) ) { continue; }
|
||||||
|
if( ( vertices[ To ].sides & vertices[ third ].sides ) != 0 ) { return false; }
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
std::array<std::array<double, 3> const *, 3> before {
|
||||||
|
&local[ corners[ 0 ] ], &local[ corners[ 1 ] ], &local[ corners[ 2 ] ] };
|
||||||
|
std::array<std::array<double, 3> const *, 3> after { before };
|
||||||
|
for( std::size_t corner = 0; corner < 3; ++corner ) {
|
||||||
|
if( corners[ corner ] == From ) { after[ corner ] = &local[ To ]; }
|
||||||
|
}
|
||||||
|
// the normal in space, so that this holds for a wall as well as for open ground
|
||||||
|
auto const normal = []( std::array<std::array<double, 3> const *, 3> const &Corners ) {
|
||||||
|
auto const &a { *Corners[ 0 ] }; auto const &b { *Corners[ 1 ] }; auto const &c { *Corners[ 2 ] };
|
||||||
|
return std::array<double, 3> {
|
||||||
|
( b[ 1 ] - a[ 1 ] ) * ( c[ 2 ] - a[ 2 ] ) - ( b[ 2 ] - a[ 2 ] ) * ( c[ 1 ] - a[ 1 ] ),
|
||||||
|
( b[ 2 ] - a[ 2 ] ) * ( c[ 0 ] - a[ 0 ] ) - ( b[ 0 ] - a[ 0 ] ) * ( c[ 2 ] - a[ 2 ] ),
|
||||||
|
( b[ 0 ] - a[ 0 ] ) * ( c[ 1 ] - a[ 1 ] ) - ( b[ 1 ] - a[ 1 ] ) * ( c[ 0 ] - a[ 0 ] ) }; };
|
||||||
|
auto const was { normal( before ) };
|
||||||
|
auto const now { normal( after ) };
|
||||||
|
auto const length {
|
||||||
|
std::sqrt( now[ 0 ] * now[ 0 ] + now[ 1 ] * now[ 1 ] + now[ 2 ] * now[ 2 ] ) };
|
||||||
|
// nothing that squashes a triangle flat, and nothing that turns one over
|
||||||
|
if( 0.5 * length < nullarea ) { return false; }
|
||||||
|
if( was[ 0 ] * now[ 0 ] + was[ 1 ] * now[ 1 ] + was[ 2 ] * now[ 2 ] <= 0.0 ) { return false; }
|
||||||
|
}
|
||||||
|
// and nothing that moves the ground further than this level is allowed to
|
||||||
|
if( drop_of( From, To ) > Allowed ) { return false; }
|
||||||
|
return true; };
|
||||||
|
|
||||||
|
auto const offer = [ & ]( std::uint32_t const Vertex ) {
|
||||||
|
for( auto const triangle : incident[ Vertex ] ) {
|
||||||
|
if( true == gone[ triangle ] ) { continue; }
|
||||||
|
for( std::size_t corner = 0; corner < 3; ++corner ) {
|
||||||
|
auto const other { cornerof( triangle, corner ) };
|
||||||
|
if( other == Vertex ) { continue; }
|
||||||
|
for( auto const [ from, to ] : { std::pair { Vertex, other }, std::pair { other, Vertex } } ) {
|
||||||
|
if( false == allowed( from, to ) ) { continue; }
|
||||||
|
queue.push( { cost_of( from, to ), from, to, version[ from ], version[ to ] } );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} };
|
||||||
|
|
||||||
|
for( std::uint32_t vertex = 0; vertex < vertices.size(); ++vertex ) { offer( vertex ); }
|
||||||
|
|
||||||
|
while( ( triangles > Budget ) && ( false == queue.empty() ) ) {
|
||||||
|
|
||||||
|
auto const best { queue.top() };
|
||||||
|
queue.pop();
|
||||||
|
if( ( version[ best.from ] != best.fromversion ) || ( version[ best.to ] != best.toversion ) ) {
|
||||||
|
continue; // one of the two has moved on since this was worked out
|
||||||
|
}
|
||||||
|
if( false == allowed( best.from, best.to ) ) { continue; }
|
||||||
|
|
||||||
|
auto const drop { drop_of( best.from, best.to ) };
|
||||||
|
moved[ best.to ] = std::max( { moved[ best.to ], moved[ best.from ], drop } );
|
||||||
|
Mesh.error = std::max( Mesh.error, drop );
|
||||||
|
|
||||||
|
removed[ best.from ] = true;
|
||||||
|
quadrics[ best.to ] += quadrics[ best.from ];
|
||||||
|
|
||||||
|
for( auto const triangle : incident[ best.from ] ) {
|
||||||
|
if( true == gone[ triangle ] ) { continue; }
|
||||||
|
auto wasthere { false };
|
||||||
|
for( std::size_t corner = 0; corner < 3; ++corner ) {
|
||||||
|
if( cornerof( triangle, corner ) == best.to ) { wasthere = true; }
|
||||||
|
}
|
||||||
|
if( true == wasthere ) {
|
||||||
|
// the triangle had both ends of the edge: it has no area left
|
||||||
|
gone[ triangle ] = true;
|
||||||
|
--triangles;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for( std::size_t corner = 0; corner < 3; ++corner ) {
|
||||||
|
if( cornerof( triangle, corner ) == best.from ) { cornerof( triangle, corner ) = best.to; }
|
||||||
|
}
|
||||||
|
incident[ best.to ].push_back( triangle );
|
||||||
|
}
|
||||||
|
incident[ best.from ].clear();
|
||||||
|
++version[ best.from ];
|
||||||
|
++version[ best.to ];
|
||||||
|
|
||||||
|
offer( best.to );
|
||||||
|
}
|
||||||
|
|
||||||
|
// drop what went, and close up the numbering
|
||||||
|
std::vector<std::uint32_t> indices;
|
||||||
|
indices.reserve( triangles * 3 );
|
||||||
|
for( std::size_t triangle = 0; triangle < gone.size(); ++triangle ) {
|
||||||
|
if( true == gone[ triangle ] ) { continue; }
|
||||||
|
for( std::size_t corner = 0; corner < 3; ++corner ) {
|
||||||
|
indices.push_back( cornerof( triangle, corner ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::vector<std::uint32_t> renumber( vertices.size(), std::numeric_limits<std::uint32_t>::max() );
|
||||||
|
std::vector<meshvertex> kept;
|
||||||
|
kept.reserve( vertices.size() );
|
||||||
|
for( auto &index : indices ) {
|
||||||
|
if( renumber[ index ] == std::numeric_limits<std::uint32_t>::max() ) {
|
||||||
|
renumber[ index ] = static_cast<std::uint32_t>( kept.size() );
|
||||||
|
kept.push_back( vertices[ index ] );
|
||||||
|
}
|
||||||
|
index = renumber[ index ];
|
||||||
|
}
|
||||||
|
Mesh.vertices.swap( kept );
|
||||||
|
Mesh.indices.swap( indices );
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
template <typename Type_>
|
||||||
|
void
|
||||||
|
append( std::vector<std::uint8_t> &Out, Type_ const &Value ) {
|
||||||
|
auto const *bytes { reinterpret_cast<std::uint8_t const *>( &Value ) };
|
||||||
|
Out.insert( Out.end(), bytes, bytes + sizeof( Type_ ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename Index_>
|
||||||
|
void
|
||||||
|
append_indices( std::vector<std::uint8_t> &Out, std::vector<std::uint32_t> const &Indices ) {
|
||||||
|
std::vector<Index_> narrow( Indices.begin(), Indices.end() );
|
||||||
|
std::vector<Index_> codes;
|
||||||
|
highwater_encode( narrow, codes );
|
||||||
|
for( auto const code : codes ) { append( Out, code ); }
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename Index_>
|
||||||
|
void
|
||||||
|
append_edge( std::vector<std::uint8_t> &Out, std::vector<std::uint32_t> const &Edge ) {
|
||||||
|
append( Out, static_cast<std::uint32_t>( Edge.size() ) );
|
||||||
|
for( auto const index : Edge ) { append( Out, static_cast<Index_>( index ) ); }
|
||||||
|
}
|
||||||
|
|
||||||
|
} // anonymous namespace
|
||||||
|
|
||||||
|
// The layout is the format's: the header, then the vertices as zigzag deltas of their quantized
|
||||||
|
// coordinates, then the triangles high-water-mark encoded, then the four lists of border
|
||||||
|
// vertices, then extensions. Texture coordinates and materials are ours, and ride in one.
|
||||||
|
void
|
||||||
|
cooker::encode( tile_address const &Tile, mesh const &Mesh, std::vector<std::uint8_t> &Out ) const {
|
||||||
|
|
||||||
|
double west { 0.0 }, north { 0.0 }, side { 0.0 };
|
||||||
|
square( Tile, west, north, side );
|
||||||
|
|
||||||
|
// The format's indices are coded against the highest one used so far, which only works when a
|
||||||
|
// vertex is numbered the first time a triangle reaches for it. So the vertices are put in the
|
||||||
|
// order the triangles ask for them; anything no triangle asks for is left out.
|
||||||
|
std::vector<std::uint32_t> order;
|
||||||
|
std::vector<std::uint32_t> indices;
|
||||||
|
{
|
||||||
|
constexpr std::uint32_t unplaced { std::numeric_limits<std::uint32_t>::max() };
|
||||||
|
std::vector<std::uint32_t> placed( Mesh.vertices.size(), unplaced );
|
||||||
|
order.reserve( Mesh.vertices.size() );
|
||||||
|
indices.reserve( Mesh.indices.size() );
|
||||||
|
for( auto const index : Mesh.indices ) {
|
||||||
|
if( placed[ index ] == unplaced ) {
|
||||||
|
placed[ index ] = static_cast<std::uint32_t>( order.size() );
|
||||||
|
order.push_back( index );
|
||||||
|
}
|
||||||
|
indices.push_back( placed[ index ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto lowest { Mesh.vertices[ order.front() ].y }, highest { lowest };
|
||||||
|
for( auto const index : order ) {
|
||||||
|
lowest = std::min( lowest, Mesh.vertices[ index ].y );
|
||||||
|
highest = std::max( highest, Mesh.vertices[ index ].y );
|
||||||
|
}
|
||||||
|
// heights are quantized against this tile's own range, as the format says. two tiles sharing
|
||||||
|
// a border therefore round a shared vertex a fraction of a millimetre differently, which is
|
||||||
|
// what the format's skirts are for elsewhere and is below anything visible here
|
||||||
|
auto const heightspan { std::max( 1e-3, highest - lowest ) };
|
||||||
|
|
||||||
|
file_header header {};
|
||||||
|
header.centre_x = west + side * 0.5;
|
||||||
|
header.centre_y = ( lowest + highest ) * 0.5;
|
||||||
|
header.centre_z = north + side * 0.5;
|
||||||
|
header.lowest = static_cast<float>( lowest );
|
||||||
|
header.highest = static_cast<float>( highest );
|
||||||
|
header.sphere_x = header.centre_x;
|
||||||
|
header.sphere_y = header.centre_y;
|
||||||
|
header.sphere_z = header.centre_z;
|
||||||
|
header.sphere_radius = std::sqrt( 0.5 * side * side + 0.25 * heightspan * heightspan );
|
||||||
|
header.horizon_x = header.horizon_y = header.horizon_z = 0.0;
|
||||||
|
|
||||||
|
Out.clear();
|
||||||
|
Out.reserve( order.size() * 8 + indices.size() * 2 + sizeof( header ) + 64 );
|
||||||
|
append( Out, header );
|
||||||
|
append( Out, static_cast<std::uint32_t>( order.size() ) );
|
||||||
|
|
||||||
|
std::vector<std::uint16_t> quantx( order.size() );
|
||||||
|
std::vector<std::uint16_t> quanty( order.size() );
|
||||||
|
std::vector<std::uint16_t> quantz( order.size() );
|
||||||
|
for( std::size_t index = 0; index < order.size(); ++index ) {
|
||||||
|
auto const &vertex { Mesh.vertices[ order[ index ] ] };
|
||||||
|
quantx[ index ] = quantize( vertex.x, west, side );
|
||||||
|
quanty[ index ] = quantize( vertex.y, lowest, heightspan );
|
||||||
|
quantz[ index ] = quantize( vertex.z, north, side );
|
||||||
|
}
|
||||||
|
// the deltas between one vertex and the next, so the run is small numbers
|
||||||
|
auto const deltas = [ &Out ]( std::vector<std::uint16_t> const &Values ) {
|
||||||
|
std::int32_t previous { 0 };
|
||||||
|
for( auto const value : Values ) {
|
||||||
|
append( Out, zigzag_encode( static_cast<std::int32_t>( value ) - previous ) );
|
||||||
|
previous = static_cast<std::int32_t>( value );
|
||||||
|
} };
|
||||||
|
deltas( quantx );
|
||||||
|
deltas( quantz );
|
||||||
|
deltas( quanty );
|
||||||
|
|
||||||
|
auto const wide { order.size() > 65536 };
|
||||||
|
auto const indexsize { wide ? 4u : 2u };
|
||||||
|
// the format wants the indices to start on a boundary of their own size
|
||||||
|
while( ( Out.size() % indexsize ) != 0 ) { Out.push_back( 0 ); }
|
||||||
|
|
||||||
|
append( Out, static_cast<std::uint32_t>( indices.size() / 3 ) );
|
||||||
|
if( true == wide ) { append_indices<std::uint32_t>( Out, indices ); }
|
||||||
|
else { append_indices<std::uint16_t>( Out, indices ); }
|
||||||
|
|
||||||
|
// the border vertices, which the format expects to be told about by the quantized coordinate
|
||||||
|
// they share
|
||||||
|
std::vector<std::uint32_t> edges[ 4 ];
|
||||||
|
for( std::size_t index = 0; index < order.size(); ++index ) {
|
||||||
|
if( quantx[ index ] == 0 ) { edges[ 0 ].push_back( static_cast<std::uint32_t>( index ) ); }
|
||||||
|
if( quantz[ index ] == 0 ) { edges[ 1 ].push_back( static_cast<std::uint32_t>( index ) ); }
|
||||||
|
if( quantx[ index ] == quantum_max ) { edges[ 2 ].push_back( static_cast<std::uint32_t>( index ) ); }
|
||||||
|
if( quantz[ index ] == quantum_max ) { edges[ 3 ].push_back( static_cast<std::uint32_t>( index ) ); }
|
||||||
|
}
|
||||||
|
for( auto const &edge : edges ) {
|
||||||
|
if( true == wide ) { append_edge<std::uint32_t>( Out, edge ); }
|
||||||
|
else { append_edge<std::uint16_t>( Out, edge ); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ours: which ground material each vertex belongs to. the format has nowhere for it
|
||||||
|
append( Out, static_cast<std::uint8_t>( ext_ground ) );
|
||||||
|
append( Out, static_cast<std::uint32_t>( order.size() * sizeof( std::uint16_t ) ) );
|
||||||
|
for( auto const index : order ) {
|
||||||
|
append( Out, Mesh.vertices[ index ].material );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace quantizedmesh
|
||||||
176
scene/quantizedmeshcooker.h
Normal file
176
scene/quantizedmeshcooker.h
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/*
|
||||||
|
This Source Code Form is subject to the
|
||||||
|
terms of the Mozilla Public License, v.
|
||||||
|
2.0. If a copy of the MPL was not
|
||||||
|
distributed with this file, You can
|
||||||
|
obtain one at
|
||||||
|
http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Cuts a scenery's ground into Cesium Quantized Mesh tiles.
|
||||||
|
//
|
||||||
|
// The source is already a mesh, so this does not triangulate anything: it cuts the triangles it
|
||||||
|
// is given along the tile grid, welds what coincides, and builds each coarser level by taking
|
||||||
|
// the four tiles below it and collapsing edges until the tile costs what one tile should. What
|
||||||
|
// it never does is invent ground - a hole in the scenery stays a hole - or move a vertex that
|
||||||
|
// sits on a tile's border, which is what lets neighbours drawn at different levels meet.
|
||||||
|
//
|
||||||
|
// Triangles standing upright have no ground in them, and are refused rather than laid: they are
|
||||||
|
// walls, and the scenery goes on drawing them itself. refusals() says which.
|
||||||
|
//
|
||||||
|
// Shared by the engine's first-run bake and the offline tool, so the two cannot drift apart.
|
||||||
|
// Millions of triangles do not fit in memory, so what is added is spilled to a scratch file and
|
||||||
|
// read back a tile at a time.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <fstream>
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "scene/quantizedmeshformat.h"
|
||||||
|
|
||||||
|
namespace quantizedmesh {
|
||||||
|
|
||||||
|
class cooker {
|
||||||
|
|
||||||
|
public:
|
||||||
|
// a corner of a source triangle, in world space, with the texture coordinates the scenery
|
||||||
|
// gave it
|
||||||
|
struct corner {
|
||||||
|
double x, y, z;
|
||||||
|
float u, v;
|
||||||
|
};
|
||||||
|
|
||||||
|
// one finished tile, handed over as the bytes of its file
|
||||||
|
using sink = std::function<void( tile_address const &, std::vector<std::uint8_t> const & )>;
|
||||||
|
|
||||||
|
cooker() = default;
|
||||||
|
~cooker();
|
||||||
|
cooker( cooker const & ) = delete;
|
||||||
|
cooker &operator=( cooker const & ) = delete;
|
||||||
|
|
||||||
|
// Tilebudget is the most triangles a tile of any level may hold. The side of a tile at the
|
||||||
|
// finest level is not given: it is worked out from how large the triangles turn out to be, since
|
||||||
|
// a tile much smaller than them would spend most of its triangles on the cuts along its own
|
||||||
|
// borders. Opens the scratch file; returns false when it cannot be written
|
||||||
|
bool begin( std::string const &Scratchpath, std::size_t Tilebudget = 8192 );
|
||||||
|
// the side of a tile at the finest level, once finish() has worked it out
|
||||||
|
double finesttile() const { return m_finesttile; }
|
||||||
|
// stops talking to standard output, for the engine
|
||||||
|
void quiet() { m_quiet = true; }
|
||||||
|
|
||||||
|
// lays one triangle of ground. the id counts from zero in the order they are added, and is
|
||||||
|
// what refusals() speaks of
|
||||||
|
void add( corner const &A, corner const &B, corner const &C, std::string_view Material );
|
||||||
|
std::size_t added() const { return m_added; }
|
||||||
|
|
||||||
|
// cuts, welds, simplifies and hands every tile to the sink, then fills the table. the
|
||||||
|
// scratch file is read several times over and removed afterwards
|
||||||
|
bool finish( sink const &Sink, terrain_table &Table );
|
||||||
|
|
||||||
|
// triangles that carry no ground, by the id add() gave them, in ascending order. only
|
||||||
|
// meaningful once finish() has run
|
||||||
|
std::vector<std::uint32_t> const &refusals() const { return m_refusals; }
|
||||||
|
|
||||||
|
// what the cook did, for the log
|
||||||
|
struct report {
|
||||||
|
std::size_t triangles { 0 }; // laid, after cutting along the grid
|
||||||
|
std::size_t refused { 0 };
|
||||||
|
std::size_t tiles { 0 };
|
||||||
|
std::size_t vertices { 0 };
|
||||||
|
std::size_t bytes { 0 };
|
||||||
|
std::uint32_t levels { 1 };
|
||||||
|
};
|
||||||
|
report const &result() const { return m_report; }
|
||||||
|
|
||||||
|
// a vertex of a tile's mesh while it is being worked on, in metres
|
||||||
|
struct meshvertex {
|
||||||
|
double x, y, z;
|
||||||
|
std::uint16_t material;
|
||||||
|
// which of the tile's four sides it sits on, one bit each. a vertex on a side is shared
|
||||||
|
// with the tile beyond it, so it survives into every level; and an edge whose two ends
|
||||||
|
// share a side is a piece of the border itself, and survives too
|
||||||
|
std::uint8_t sides { 0 };
|
||||||
|
bool locked() const { return sides != 0; }
|
||||||
|
};
|
||||||
|
|
||||||
|
struct mesh {
|
||||||
|
std::vector<meshvertex> vertices;
|
||||||
|
std::vector<std::uint32_t> indices;
|
||||||
|
double error { 0.0 }; // metres this mesh departs from the finest one
|
||||||
|
bool empty() const { return indices.empty(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
private:
|
||||||
|
// a triangle as it waits in the scratch file. fixed size, so a tile's triangles can be read
|
||||||
|
// back by their place in it. only the corners' positions matter by then: the texture
|
||||||
|
// coordinates have already been turned into the material's tiling
|
||||||
|
struct spilled {
|
||||||
|
double corners[ 9 ];
|
||||||
|
std::uint16_t material;
|
||||||
|
std::uint16_t padding[ 3 ];
|
||||||
|
};
|
||||||
|
|
||||||
|
std::uint16_t material_index( std::string_view Material );
|
||||||
|
// how many metres of ground one repeat of a material covers, from the texture coordinates
|
||||||
|
// the scenery drew it with: the ratio of world distance to texture distance along an edge
|
||||||
|
void measure_repeat( std::uint16_t Material, corner const ( &Corners )[ 3 ] );
|
||||||
|
// the square the root tile covers, from what was added
|
||||||
|
void measure();
|
||||||
|
// cooks a tile and everything under it, depth first, and hands each to the sink. returns the
|
||||||
|
// tile's own mesh, which its parent merges
|
||||||
|
mesh cook( tile_address const &Tile, sink const &Sink );
|
||||||
|
// the finest level: the triangles of the scratch file that fall on this tile, cut to it
|
||||||
|
mesh lay( tile_address const &Tile );
|
||||||
|
// the four tiles below this one, welded into one mesh
|
||||||
|
mesh merge( tile_address const &Tile, sink const &Sink );
|
||||||
|
// collapses edges until the mesh fits the budget, or until the ground would move further than
|
||||||
|
// Allowed, whichever comes first. locked vertices are left where they are
|
||||||
|
void simplify( mesh &Mesh, std::size_t Budget, double Allowed );
|
||||||
|
// the bytes of a tile's file
|
||||||
|
void encode( tile_address const &Tile, mesh const &Mesh, std::vector<std::uint8_t> &Out ) const;
|
||||||
|
// where a tile's square lies
|
||||||
|
void square( tile_address const &Tile, double &West, double &North, double &Side ) const;
|
||||||
|
|
||||||
|
std::string m_scratchpath;
|
||||||
|
std::fstream m_scratch;
|
||||||
|
std::size_t m_added { 0 };
|
||||||
|
bool m_quiet { false };
|
||||||
|
|
||||||
|
double m_finesttile { 256.0 };
|
||||||
|
std::size_t m_tilebudget { 8192 };
|
||||||
|
// a sample of how long a triangle's edges are in the plan, which decides the finest tile
|
||||||
|
std::vector<double> m_edges;
|
||||||
|
|
||||||
|
std::vector<std::string> m_materials;
|
||||||
|
std::unordered_map<std::string, std::uint16_t> m_materiallookup;
|
||||||
|
std::vector<double> m_repeatsums;
|
||||||
|
std::vector<double> m_repeatsquares;
|
||||||
|
std::vector<std::size_t> m_repeatcounts;
|
||||||
|
|
||||||
|
// extent and height range of everything added
|
||||||
|
double m_minx { 0.0 }, m_maxx { 0.0 };
|
||||||
|
double m_minz { 0.0 }, m_maxz { 0.0 };
|
||||||
|
double m_lowest { 0.0 }, m_highest { 0.0 };
|
||||||
|
bool m_anything { false };
|
||||||
|
|
||||||
|
// the root square and the depth under it
|
||||||
|
double m_west { 0.0 }, m_north { 0.0 }, m_side { 0.0 };
|
||||||
|
std::uint32_t m_levels { 1 };
|
||||||
|
|
||||||
|
// which triangles of the scratch file touch which finest tile, sorted by tile. built once,
|
||||||
|
// walked as the finest level is laid
|
||||||
|
std::vector<std::pair<std::int64_t, std::uint32_t>> m_placement;
|
||||||
|
|
||||||
|
std::vector<std::uint32_t> m_refusals;
|
||||||
|
// the worst error any tile of each level came out with
|
||||||
|
std::vector<double> m_errors;
|
||||||
|
report m_report;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace quantizedmesh
|
||||||
@@ -9,155 +9,191 @@ http://mozilla.org/MPL/2.0/.
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
// Cesium Quantized Mesh format - TIN-based terrain tiles with LOD.
|
// Cesium Quantized Mesh, as the terrain tiles are written.
|
||||||
// Replaces regular heightfield with adaptive triangulation.
|
// https://github.com/CesiumGS/quantized-mesh
|
||||||
//
|
//
|
||||||
// Format: https://github.com/CesiumGS/quantized-mesh
|
// The ground in a MaSzyna scenery is already an irregular mesh: fine along the track where it
|
||||||
//
|
// came from a survey, coarse in the open where it was drawn by hand. The cook keeps that mesh
|
||||||
// File layout:
|
// rather than resampling it - it cuts it into tiles, welds the vertices and builds coarser
|
||||||
// header (88 bytes)
|
// levels of it - so there is nothing to interpolate, nothing to fill in where the ground has
|
||||||
// vertex data (positions, uvs, indices)
|
// holes, and no grid step to pick.
|
||||||
// edge indices (for seamless LOD stitching)
|
|
||||||
// extensions (optional metadata)
|
|
||||||
//
|
//
|
||||||
// Advantages over heightfield:
|
// Tiles form a quadtree, as the format intends: one tile at the root covers the whole terrain,
|
||||||
// - Variable density: fine detail where needed (track corridor), coarse elsewhere
|
// and each level splits every tile into four. A distant tile is therefore a large one, and the
|
||||||
// - ~50% fewer vertices for same visual quality on mixed-detail terrain
|
// horizon costs tens of tiles rather than thousands. Every vertex on a tile's border survives
|
||||||
// - Built-in LOD with proper edge constraints (no T-junctions)
|
// into all of its coarser levels, so two neighbours drawn at different levels share their
|
||||||
// - Industry-standard format, tooling available
|
// border exactly, and nothing has to be stitched or morphed at draw time.
|
||||||
//
|
//
|
||||||
// Disadvantages:
|
// Where this departs from the specification, and why:
|
||||||
// - More complex to generate (requires constrained triangulation)
|
//
|
||||||
// - Slightly larger per-tile overhead (index buffer)
|
// - the header's fields are geodetic (ECEF centre, bounding sphere, horizon occlusion point).
|
||||||
|
// A scenery has a local metric frame and no geographic reference to convert from, so those
|
||||||
|
// fields carry scenery metres. The geometry reads back correctly anywhere; a Cesium client
|
||||||
|
// would place the tile wrongly on the globe, which is a trade for something we do not want.
|
||||||
|
// - the format has no texture coordinates and no materials: its u and v are the vertex's
|
||||||
|
// horizontal position within the tile. A vertex of ours carries which ground material it
|
||||||
|
// belongs to, in extension ext_ground below, which is what the extension mechanism is for.
|
||||||
|
// Texture coordinates are not stored at all: ground textures tile in world space, so the
|
||||||
|
// coordinate follows from where the vertex is and how many metres one repeat of its material
|
||||||
|
// covers, which is measured from the scenery once and kept in the terrain's table.
|
||||||
|
// - tiles are stored inside the scenery's archive, one zstd-compressed entry each, in place of
|
||||||
|
// the gzip the format expects from an HTTP layer. The bytes of a tile are the format's.
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cmath>
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace quantizedmesh {
|
namespace quantizedmesh {
|
||||||
|
|
||||||
// "EU07QMSH" - distinguishes from other formats
|
// u, v and height are quantized to this, not to 65535: the format says so
|
||||||
inline constexpr char magic[ 8 ] { 'E', 'U', '0', '7', 'Q', 'M', 'S', 'H' };
|
inline constexpr std::uint16_t quantum_max { 32767 };
|
||||||
inline constexpr std::uint32_t version { 1 };
|
|
||||||
|
// what the cook counted as ground, and how it cut and simplified it. tiles cooked under other
|
||||||
|
// rules are baked again rather than read
|
||||||
|
inline constexpr std::uint32_t cook_rules { 2 };
|
||||||
|
|
||||||
#pragma pack( push, 1 )
|
#pragma pack( push, 1 )
|
||||||
|
|
||||||
|
// the format's header, unchanged. 88 bytes
|
||||||
struct file_header {
|
struct file_header {
|
||||||
char magic[ 8 ];
|
double centre_x, centre_y, centre_z;
|
||||||
std::uint32_t version;
|
float lowest, highest; // the tile's height range, in metres
|
||||||
|
double sphere_x, sphere_y, sphere_z; // bounding sphere, for culling
|
||||||
// Tile bounds in world space (metres)
|
double sphere_radius;
|
||||||
double center_x, center_y, center_z; // tile centre
|
double horizon_x, horizon_y, horizon_z; // horizon occlusion point; unused here
|
||||||
double min_x, min_y, min_z; // bounding box min
|
|
||||||
double max_x, max_y, max_z; // bounding box max
|
|
||||||
|
|
||||||
// Quantization ranges - vertices stored as uint16, decoded to this range
|
|
||||||
double horizont_error; // maximum geometric error for this LOD
|
|
||||||
|
|
||||||
std::uint32_t vertex_count;
|
|
||||||
std::uint32_t triangle_count;
|
|
||||||
|
|
||||||
// Offsets into file (all after this header)
|
|
||||||
std::uint32_t vertex_data_offset; // packed uint16[3] positions
|
|
||||||
std::uint32_t uv_data_offset; // packed uint16[2] texture coords
|
|
||||||
std::uint32_t index_data_offset; // uint16 indices (or uint32 if >65535 verts)
|
|
||||||
std::uint32_t edge_indices_offset; // seamless stitching data
|
|
||||||
std::uint32_t extension_offset; // optional metadata
|
|
||||||
|
|
||||||
std::uint16_t index_size; // 2 (uint16) or 4 (uint32)
|
|
||||||
std::uint16_t edge_count_north;
|
|
||||||
std::uint16_t edge_count_south;
|
|
||||||
std::uint16_t edge_count_west;
|
|
||||||
std::uint16_t edge_count_east;
|
|
||||||
std::uint16_t reserved; // padding for future use
|
|
||||||
};
|
};
|
||||||
|
|
||||||
static_assert( sizeof( file_header ) == 132 ); // Actual size with current layout
|
static_assert( sizeof( file_header ) == 88 );
|
||||||
|
|
||||||
// Edge indices for seamless LOD transitions
|
|
||||||
// Each edge stores indices of vertices lying on that tile boundary, in order
|
|
||||||
// Parent LOD uses these to stitch to child tiles without gaps
|
|
||||||
struct edge_indices {
|
|
||||||
std::uint16_t count;
|
|
||||||
std::uint16_t indices[]; // variable length
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optional extensions (material splat, metadata, etc.)
|
|
||||||
struct extension_header {
|
|
||||||
std::uint32_t extension_id;
|
|
||||||
std::uint32_t extension_size;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extension IDs
|
|
||||||
enum extension_id : std::uint32_t {
|
|
||||||
ext_material_splat = 1, // RGBA8 per-vertex material weights
|
|
||||||
ext_metadata = 2, // JSON metadata
|
|
||||||
ext_meshlet = 3, // Meshlet data for mesh shading
|
|
||||||
};
|
|
||||||
|
|
||||||
#pragma pack( pop )
|
#pragma pack( pop )
|
||||||
|
|
||||||
// Helper functions
|
// extension ids. 1, 2 and 4 are taken by the format for normals, the water mask and metadata
|
||||||
|
enum extension_id : std::uint8_t {
|
||||||
|
ext_normals = 1,
|
||||||
|
ext_watermask = 2,
|
||||||
|
ext_metadata = 4,
|
||||||
|
// ours: one uint16 per vertex, its material's place in the terrain's table
|
||||||
|
ext_ground = 64,
|
||||||
|
};
|
||||||
|
|
||||||
// Quantize position to uint16
|
// zigzag, as the format uses it on the deltas between consecutive vertices
|
||||||
inline std::uint16_t quantize( double value, double min, double max ) {
|
inline std::uint16_t
|
||||||
if( max <= min ) return 0;
|
zigzag_encode( std::int32_t const Value ) {
|
||||||
double const normalized = std::clamp( ( value - min ) / ( max - min ), 0.0, 1.0 );
|
return static_cast<std::uint16_t>( ( Value << 1 ) ^ ( Value >> 31 ) );
|
||||||
return static_cast<std::uint16_t>( normalized * 65535.0 + 0.5 );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dequantize uint16 to position
|
inline std::int32_t
|
||||||
inline double dequantize( std::uint16_t encoded, double min, double max ) {
|
zigzag_decode( std::uint16_t const Stored ) {
|
||||||
double const normalized = static_cast<double>( encoded ) / 65535.0;
|
return ( Stored >> 1 ) ^ -static_cast<std::int32_t>( Stored & 1 );
|
||||||
return min + normalized * ( max - min );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ZigZag encode for delta-compression of indices
|
// a quantized coordinate back to metres, against the range it was quantized in
|
||||||
inline std::uint32_t zigzag_encode( std::int32_t value ) {
|
inline double
|
||||||
return ( value << 1 ) ^ ( value >> 31 );
|
dequantize( std::uint16_t const Stored, double const Low, double const Span ) {
|
||||||
|
return Low + ( static_cast<double>( Stored ) / quantum_max ) * Span;
|
||||||
}
|
}
|
||||||
|
|
||||||
inline std::int32_t zigzag_decode( std::uint32_t value ) {
|
inline std::uint16_t
|
||||||
return ( value >> 1 ) ^ -static_cast<std::int32_t>( value & 1 );
|
quantize( double const Value, double const Low, double const Span ) {
|
||||||
|
if( Span <= 0.0 ) { return 0; }
|
||||||
|
auto const share { std::clamp( ( Value - Low ) / Span, 0.0, 1.0 ) };
|
||||||
|
return static_cast<std::uint16_t>( share * quantum_max + 0.5 );
|
||||||
}
|
}
|
||||||
|
|
||||||
// Octahedral encoding for normals (optional, saves space)
|
// Indices are stored high-water-mark encoded: a code of zero means the next index never used
|
||||||
inline void encode_oct( float nx, float ny, float nz, std::int16_t &out_x, std::int16_t &out_y ) {
|
// before, and anything else counts back from the highest index used so far. The run of codes is
|
||||||
float const l1norm = std::abs(nx) + std::abs(ny) + std::abs(nz);
|
// small numbers whatever the mesh looks like, which is what makes the tile compress.
|
||||||
float px = nx / l1norm;
|
template <typename Index_>
|
||||||
float py = ny / l1norm;
|
void
|
||||||
|
highwater_encode( std::vector<Index_> const &Indices, std::vector<Index_> &Out ) {
|
||||||
if( nz < 0.0f ) {
|
|
||||||
float const old_x = px;
|
Out.clear();
|
||||||
px = ( 1.0f - std::abs(py) ) * ( old_x >= 0.0f ? 1.0f : -1.0f );
|
Out.reserve( Indices.size() );
|
||||||
py = ( 1.0f - std::abs(old_x) ) * ( py >= 0.0f ? 1.0f : -1.0f );
|
std::int64_t highest { 0 };
|
||||||
|
for( auto const index : Indices ) {
|
||||||
|
Out.push_back( static_cast<Index_>( highest - static_cast<std::int64_t>( index ) ) );
|
||||||
|
if( static_cast<std::int64_t>( index ) == highest ) { ++highest; }
|
||||||
}
|
}
|
||||||
|
|
||||||
out_x = static_cast<std::int16_t>( std::clamp( px * 32767.0f, -32767.0f, 32767.0f ) );
|
|
||||||
out_y = static_cast<std::int16_t>( std::clamp( py * 32767.0f, -32767.0f, 32767.0f ) );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
inline void decode_oct( std::int16_t enc_x, std::int16_t enc_y, float &nx, float &ny, float &nz ) {
|
// returns false when a code points past what has been used, which a corrupted tile can do
|
||||||
float px = static_cast<float>( enc_x ) / 32767.0f;
|
template <typename Index_>
|
||||||
float py = static_cast<float>( enc_y ) / 32767.0f;
|
bool
|
||||||
|
highwater_decode( std::vector<Index_> const &Codes, std::vector<std::uint32_t> &Out ) {
|
||||||
nz = 1.0f - std::abs(px) - std::abs(py);
|
|
||||||
|
Out.clear();
|
||||||
if( nz < 0.0f ) {
|
Out.reserve( Codes.size() );
|
||||||
float const old_x = px;
|
std::int64_t highest { 0 };
|
||||||
px = ( 1.0f - std::abs(py) ) * ( old_x >= 0.0f ? 1.0f : -1.0f );
|
for( auto const code : Codes ) {
|
||||||
py = ( 1.0f - std::abs(old_x) ) * ( py >= 0.0f ? 1.0f : -1.0f );
|
auto const index { highest - static_cast<std::int64_t>( code ) };
|
||||||
|
if( ( index < 0 ) || ( index > highest ) ) { return false; }
|
||||||
|
Out.push_back( static_cast<std::uint32_t>( index ) );
|
||||||
|
if( index == highest ) { ++highest; }
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
float const len = std::sqrt( px*px + py*py + nz*nz );
|
|
||||||
nx = px / len;
|
|
||||||
ny = py / len;
|
|
||||||
nz = nz / len;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tile key - same as heightfield for compatibility
|
// A tile's place in the quadtree. Level 0 is the root, covering the whole terrain; each level
|
||||||
|
// splits every tile into four. X grows east, Z grows with the scenery's z, so a tile's square
|
||||||
|
// follows from the terrain's extent and the level alone.
|
||||||
|
struct tile_address {
|
||||||
|
std::uint32_t level { 0 };
|
||||||
|
std::int32_t x { 0 }, z { 0 };
|
||||||
|
|
||||||
|
bool operator==( tile_address const & ) const = default;
|
||||||
|
};
|
||||||
|
|
||||||
constexpr std::int64_t
|
constexpr std::int64_t
|
||||||
tile_key( std::int32_t const X, std::int32_t const Z ) {
|
tile_key( tile_address const &Tile ) {
|
||||||
return ( static_cast<std::int64_t>( X ) << 32 ) ^ static_cast<std::uint32_t>( Z );
|
// a level fits in six bits for any terrain worth cooking, and the coordinates cannot exceed
|
||||||
|
// the level, so this is unique
|
||||||
|
return ( static_cast<std::int64_t>( Tile.level ) << 58 )
|
||||||
|
^ ( static_cast<std::int64_t>( Tile.x ) << 29 )
|
||||||
|
^ static_cast<std::int64_t>( Tile.z );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// where a tile's file sits in the archive, following the format's own layout of level, column
|
||||||
|
// and row
|
||||||
|
inline std::string
|
||||||
|
tile_name( tile_address const &Tile ) {
|
||||||
|
return std::to_string( Tile.level ) + "/" + std::to_string( Tile.x ) + "/"
|
||||||
|
+ std::to_string( Tile.z ) + ".terrain";
|
||||||
|
}
|
||||||
|
|
||||||
|
// the terrain's extent, height range, depth and material table are the same for every tile, so
|
||||||
|
// they are written once beside them, under this name. the format keeps the same thing in
|
||||||
|
// layer.json
|
||||||
|
inline constexpr char const *tablename { "terrain.table" };
|
||||||
|
|
||||||
|
// what that file says
|
||||||
|
struct terrain_table {
|
||||||
|
std::uint32_t rules { 0 };
|
||||||
|
// the square the root tile covers. always square, so that a tile at any level is too
|
||||||
|
double west { 0.0 }, north { 0.0 }, side { 0.0 };
|
||||||
|
double lowest { 0.0 }, highest { 0.0 };
|
||||||
|
// levels cooked, root included: addresses run from 0 to levels - 1
|
||||||
|
std::uint32_t levels { 1 };
|
||||||
|
// metres the ground of a tile at each level departs from the finest one. what decides which
|
||||||
|
// level to draw: a level is good enough while its error covers few enough pixels
|
||||||
|
std::vector<float> errors;
|
||||||
|
// one ground texture: its name, and how many metres of ground one repeat of it covers, as
|
||||||
|
// measured from the texture coordinates the scenery drew it with
|
||||||
|
struct material {
|
||||||
|
std::string name;
|
||||||
|
float repeat { 4.f };
|
||||||
|
};
|
||||||
|
// in the order the vertices' material indices count
|
||||||
|
std::vector<material> materials;
|
||||||
|
|
||||||
|
// metres along the side of a tile at this level
|
||||||
|
double tilesize( std::uint32_t const Level ) const {
|
||||||
|
return side / static_cast<double>( 1u << Level );
|
||||||
|
}
|
||||||
|
float error( std::uint32_t const Level ) const {
|
||||||
|
return Level < errors.size() ? errors[ Level ] : 0.f;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
} // namespace quantizedmesh
|
} // namespace quantizedmesh
|
||||||
|
|||||||
@@ -9,215 +9,369 @@ http://mozilla.org/MPL/2.0/.
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
// Reads Cesium Quantized Mesh tiles - replaces heightfield reader entirely.
|
// Reads cooked terrain tiles back, from a scenery's archive or from a directory of loose files.
|
||||||
// Used by terrain_tile_loader to stream TIN tiles from disk.
|
// A directory is what a tile cooked by hand or by another tool arrives as; the archive is what
|
||||||
|
// the bake writes.
|
||||||
|
//
|
||||||
|
// Everything a tile says about itself is checked before it is believed: a truncated or mangled
|
||||||
|
// tile is refused rather than handed on, because what comes out of here goes straight into a
|
||||||
|
// vertex buffer and an index buffer.
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <memory>
|
|
||||||
|
|
||||||
|
#include "scene/pakformat.h"
|
||||||
#include "scene/quantizedmeshformat.h"
|
#include "scene/quantizedmeshformat.h"
|
||||||
|
|
||||||
namespace quantizedmesh {
|
namespace quantizedmesh {
|
||||||
|
|
||||||
// Reader for cooked TIN terrain tiles (.qm files)
|
// one vertex as the renderer wants it: measured from the terrain's origin, so that the whole
|
||||||
// Single-threaded, meant to be used from terrain_tile_loader worker thread
|
// terrain fits in floats and every tile of it can go into one vertex buffer. there are no texture
|
||||||
|
// coordinates - the ground tiles in world space, so the shader works them out from the position
|
||||||
|
#pragma pack( push, 1 )
|
||||||
|
struct render_vertex {
|
||||||
|
float x, y, z;
|
||||||
|
std::uint16_t material;
|
||||||
|
std::uint16_t padding;
|
||||||
|
};
|
||||||
|
#pragma pack( pop )
|
||||||
|
|
||||||
|
static_assert( sizeof( render_vertex ) == 16 );
|
||||||
|
|
||||||
class reader {
|
class reader {
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
struct tile_data {
|
||||||
|
tile_address tile;
|
||||||
|
std::vector<render_vertex> vertices;
|
||||||
|
std::vector<std::uint32_t> indices;
|
||||||
|
// what the vertices are measured from, in scenery metres
|
||||||
|
double originx { 0.0 }, originy { 0.0 }, originz { 0.0 };
|
||||||
|
// for culling, in scenery metres
|
||||||
|
double centrex { 0.0 }, centrey { 0.0 }, centrez { 0.0 };
|
||||||
|
double radius { 0.0 };
|
||||||
|
float lowest { 0.f }, highest { 0.f };
|
||||||
|
};
|
||||||
|
|
||||||
reader() = default;
|
reader() = default;
|
||||||
~reader() { close(); }
|
~reader() { close(); }
|
||||||
|
|
||||||
reader( reader const & ) = delete;
|
reader( reader const & ) = delete;
|
||||||
reader &operator=( reader const & ) = delete;
|
reader &operator=( reader const & ) = delete;
|
||||||
|
|
||||||
// Open tile directory (contains terrain_X_Z_lodN.qm files)
|
// an archive or a directory. reads the terrain's table, without which nothing can be placed
|
||||||
bool open( std::string const &Directory );
|
bool open( std::string const &Path );
|
||||||
void close();
|
void close();
|
||||||
|
bool ready() const { return m_ready; }
|
||||||
// Read single LOD of a tile
|
|
||||||
struct tile_data {
|
terrain_table const &table() const { return m_table; }
|
||||||
// Dequantized positions
|
std::string const &path() const { return m_path; }
|
||||||
std::vector<double> positions_x;
|
|
||||||
std::vector<double> positions_y;
|
// Origin is what the vertices come back measured from: one point for the whole terrain, so
|
||||||
std::vector<double> positions_z;
|
// that every tile can go into the same vertex buffer and be drawn in one call
|
||||||
|
bool read_tile( tile_address const &Tile, tile_data &Out,
|
||||||
// Texture coordinates
|
double Originx, double Originy, double Originz );
|
||||||
std::vector<float> uvs_u;
|
// the tiles the source holds, by tile_key. a tile the cook found nothing for is not there
|
||||||
std::vector<float> uvs_v;
|
std::unordered_set<std::int64_t> const &present() const { return m_present; }
|
||||||
|
bool contains( tile_address const &Tile ) const { return m_present.contains( tile_key( Tile ) ); }
|
||||||
// Triangle indices
|
|
||||||
std::vector<std::uint32_t> indices;
|
|
||||||
|
|
||||||
// Material per vertex (index into material table)
|
|
||||||
std::vector<std::uint8_t> materials;
|
|
||||||
|
|
||||||
// Edge indices for stitching
|
|
||||||
std::vector<std::uint16_t> north_edge;
|
|
||||||
std::vector<std::uint16_t> south_edge;
|
|
||||||
std::vector<std::uint16_t> west_edge;
|
|
||||||
std::vector<std::uint16_t> east_edge;
|
|
||||||
|
|
||||||
// Tile bounds (for positioning)
|
|
||||||
double center_x, center_y, center_z;
|
|
||||||
double min_x, min_y, min_z;
|
|
||||||
double max_x, max_y, max_z;
|
|
||||||
|
|
||||||
double geometric_error;
|
|
||||||
};
|
|
||||||
|
|
||||||
bool read_tile( std::int32_t X, std::int32_t Z, std::uint32_t LOD, tile_data &Out );
|
|
||||||
|
|
||||||
// Check if tile exists
|
|
||||||
bool contains( std::int32_t X, std::int32_t Z, std::uint32_t LOD ) const;
|
|
||||||
|
|
||||||
std::string const &directory() const { return m_directory; }
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::string tile_path( std::int32_t X, std::int32_t Z, std::uint32_t LOD ) const;
|
bool entry( std::string const &Name, std::vector<std::uint8_t> &Out );
|
||||||
|
bool read_table();
|
||||||
std::string m_directory;
|
void find_tiles();
|
||||||
|
|
||||||
|
std::string m_path;
|
||||||
|
bool m_archive { false };
|
||||||
|
pak::reader m_pak;
|
||||||
|
terrain_table m_table;
|
||||||
|
std::unordered_set<std::int64_t> m_present;
|
||||||
|
bool m_ready { false };
|
||||||
|
std::vector<std::uint8_t> m_buffer;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Implementation
|
// Implementation.
|
||||||
|
|
||||||
|
inline bool
|
||||||
|
reader::open( std::string const &Path ) {
|
||||||
|
|
||||||
inline bool reader::open( std::string const &Directory ) {
|
|
||||||
close();
|
close();
|
||||||
m_directory = Directory;
|
m_path = Path;
|
||||||
return true; // Lazy - tiles opened on demand
|
std::error_code error;
|
||||||
}
|
if( true == std::filesystem::is_directory( Path, error ) ) {
|
||||||
|
m_archive = false;
|
||||||
inline void reader::close() {
|
}
|
||||||
m_directory.clear();
|
else if( true == std::filesystem::is_regular_file( Path, error ) ) {
|
||||||
}
|
m_archive = true;
|
||||||
|
if( false == m_pak.open( Path ) ) { close(); return false; }
|
||||||
inline std::string reader::tile_path( std::int32_t X, std::int32_t Z, std::uint32_t LOD ) const {
|
}
|
||||||
char filename[ 256 ];
|
else {
|
||||||
std::snprintf( filename, sizeof( filename ), "terrain_%d_%d_lod%u.qm", X, Z, LOD );
|
close();
|
||||||
return m_directory + "/" + filename;
|
return false;
|
||||||
}
|
}
|
||||||
|
if( false == read_table() ) { close(); return false; }
|
||||||
inline bool reader::contains( std::int32_t X, std::int32_t Z, std::uint32_t LOD ) const {
|
find_tiles();
|
||||||
auto const path = tile_path( X, Z, LOD );
|
m_ready = true;
|
||||||
FILE *f = std::fopen( path.c_str(), "rb" );
|
|
||||||
if( !f ) return false;
|
|
||||||
std::fclose( f );
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
inline bool reader::read_tile( std::int32_t X, std::int32_t Z, std::uint32_t LOD, tile_data &Out ) {
|
inline void
|
||||||
auto const path = tile_path( X, Z, LOD );
|
reader::close() {
|
||||||
|
|
||||||
FILE *file = std::fopen( path.c_str(), "rb" );
|
m_pak.close();
|
||||||
if( !file ) return false;
|
m_table = {};
|
||||||
|
m_present.clear();
|
||||||
// Read header
|
m_path.clear();
|
||||||
file_header header;
|
m_archive = false;
|
||||||
if( std::fread( &header, sizeof( header ), 1, file ) != 1 ) {
|
m_ready = false;
|
||||||
std::fclose( file );
|
}
|
||||||
return false;
|
|
||||||
|
inline bool
|
||||||
|
reader::entry( std::string const &Name, std::vector<std::uint8_t> &Out ) {
|
||||||
|
|
||||||
|
if( true == m_archive ) { return m_pak.read( Name, Out ); }
|
||||||
|
|
||||||
|
std::ifstream file( std::filesystem::path( m_path ) / Name, std::ios::binary );
|
||||||
|
if( false == file.good() ) { return false; }
|
||||||
|
file.seekg( 0, std::ios::end );
|
||||||
|
auto const size { static_cast<std::size_t>( file.tellg() ) };
|
||||||
|
file.seekg( 0, std::ios::beg );
|
||||||
|
Out.resize( size );
|
||||||
|
if( size > 0 ) {
|
||||||
|
file.read( reinterpret_cast<char *>( Out.data() ), static_cast<std::streamsize>( size ) );
|
||||||
}
|
}
|
||||||
|
return static_cast<std::size_t>( file.gcount() ) == size;
|
||||||
// Verify magic
|
}
|
||||||
if( std::memcmp( header.magic, magic, sizeof( magic ) ) != 0 ) {
|
|
||||||
std::fclose( file );
|
// the table is text: a terrain outlives the build that cooked it, and a line of it should be
|
||||||
return false;
|
// readable without a tool
|
||||||
}
|
inline bool
|
||||||
|
reader::read_table() {
|
||||||
if( header.version != version ) {
|
|
||||||
std::fclose( file );
|
std::vector<std::uint8_t> bytes;
|
||||||
return false;
|
if( false == entry( tablename, bytes ) ) { return false; }
|
||||||
}
|
std::string const text( bytes.begin(), bytes.end() );
|
||||||
|
|
||||||
// Store bounds
|
auto found { false };
|
||||||
Out.center_x = header.center_x;
|
std::size_t at { 0 };
|
||||||
Out.center_y = header.center_y;
|
while( at < text.size() ) {
|
||||||
Out.center_z = header.center_z;
|
auto const end { std::min( text.find( '\n', at ), text.size() ) };
|
||||||
Out.min_x = header.min_x;
|
std::string const line { text.substr( at, end - at ) };
|
||||||
Out.min_y = header.min_y;
|
at = end + 1;
|
||||||
Out.min_z = header.min_z;
|
if( line.empty() ) { continue; }
|
||||||
Out.max_x = header.max_x;
|
auto const space { line.find( ' ' ) };
|
||||||
Out.max_y = header.max_y;
|
std::string const what { line.substr( 0, space ) };
|
||||||
Out.max_z = header.max_z;
|
std::string const rest { space == std::string::npos ? std::string {} : line.substr( space + 1 ) };
|
||||||
Out.geometric_error = header.horizont_error;
|
if( what == "eu07-terrain" ) { found = true; }
|
||||||
|
else if( what == "rules" ) { m_table.rules = static_cast<std::uint32_t>( std::strtoul( rest.c_str(), nullptr, 10 ) ); }
|
||||||
// Read quantized positions
|
else if( what == "levels" ) { m_table.levels = static_cast<std::uint32_t>( std::strtoul( rest.c_str(), nullptr, 10 ) ); }
|
||||||
std::fseek( file, header.vertex_data_offset, SEEK_SET );
|
else if( what == "square" ) {
|
||||||
|
char const *cursor { rest.c_str() };
|
||||||
std::vector<std::uint16_t> quantized_x( header.vertex_count );
|
m_table.west = std::strtod( cursor, const_cast<char **>( &cursor ) );
|
||||||
std::vector<std::uint16_t> quantized_y( header.vertex_count );
|
m_table.north = std::strtod( cursor, const_cast<char **>( &cursor ) );
|
||||||
std::vector<std::uint16_t> quantized_z( header.vertex_count );
|
m_table.side = std::strtod( cursor, const_cast<char **>( &cursor ) );
|
||||||
|
}
|
||||||
for( std::uint32_t i = 0; i < header.vertex_count; ++i ) {
|
else if( what == "height" ) {
|
||||||
std::fread( &quantized_x[ i ], sizeof( std::uint16_t ), 1, file );
|
char const *cursor { rest.c_str() };
|
||||||
std::fread( &quantized_y[ i ], sizeof( std::uint16_t ), 1, file );
|
m_table.lowest = std::strtod( cursor, const_cast<char **>( &cursor ) );
|
||||||
std::fread( &quantized_z[ i ], sizeof( std::uint16_t ), 1, file );
|
m_table.highest = std::strtod( cursor, const_cast<char **>( &cursor ) );
|
||||||
}
|
}
|
||||||
|
else if( what == "material" ) {
|
||||||
// Dequantize positions
|
// name first, then how many metres one repeat covers. a name can hold spaces, so the
|
||||||
Out.positions_x.resize( header.vertex_count );
|
// number is taken off the end
|
||||||
Out.positions_y.resize( header.vertex_count );
|
auto const gap { rest.rfind( ' ' ) };
|
||||||
Out.positions_z.resize( header.vertex_count );
|
if( gap == std::string::npos ) { m_table.materials.push_back( { rest, 4.f } ); }
|
||||||
|
else {
|
||||||
for( std::uint32_t i = 0; i < header.vertex_count; ++i ) {
|
m_table.materials.push_back(
|
||||||
Out.positions_x[ i ] = dequantize( quantized_x[ i ], header.min_x, header.max_x );
|
{ rest.substr( 0, gap ), static_cast<float>( std::strtod( rest.c_str() + gap + 1, nullptr ) ) } );
|
||||||
Out.positions_y[ i ] = dequantize( quantized_y[ i ], header.min_y, header.max_y );
|
}
|
||||||
Out.positions_z[ i ] = dequantize( quantized_z[ i ], header.min_z, header.max_z );
|
}
|
||||||
}
|
else if( what == "error" ) {
|
||||||
|
char const *cursor { rest.c_str() };
|
||||||
// Read UVs
|
auto const level { std::strtoul( cursor, const_cast<char **>( &cursor ), 10 ) };
|
||||||
std::fseek( file, header.uv_data_offset, SEEK_SET );
|
auto const metres { std::strtod( cursor, const_cast<char **>( &cursor ) ) };
|
||||||
|
if( m_table.errors.size() <= level ) { m_table.errors.resize( level + 1, 0.f ); }
|
||||||
Out.uvs_u.resize( header.vertex_count );
|
m_table.errors[ level ] = static_cast<float>( metres );
|
||||||
Out.uvs_v.resize( header.vertex_count );
|
|
||||||
|
|
||||||
for( std::uint32_t i = 0; i < header.vertex_count; ++i ) {
|
|
||||||
std::uint16_t u, v;
|
|
||||||
std::fread( &u, sizeof( std::uint16_t ), 1, file );
|
|
||||||
std::fread( &v, sizeof( std::uint16_t ), 1, file );
|
|
||||||
Out.uvs_u[ i ] = static_cast<float>( dequantize( u, 0.0, 1.0 ) );
|
|
||||||
Out.uvs_v[ i ] = static_cast<float>( dequantize( v, 0.0, 1.0 ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read indices
|
|
||||||
std::fseek( file, header.index_data_offset, SEEK_SET );
|
|
||||||
|
|
||||||
Out.indices.resize( header.triangle_count * 3 );
|
|
||||||
|
|
||||||
if( header.index_size == 2 ) {
|
|
||||||
for( std::uint32_t i = 0; i < header.triangle_count * 3; ++i ) {
|
|
||||||
std::uint16_t idx;
|
|
||||||
std::fread( &idx, sizeof( std::uint16_t ), 1, file );
|
|
||||||
Out.indices[ i ] = idx;
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
std::fread( Out.indices.data(), sizeof( std::uint32_t ), header.triangle_count * 3, file );
|
|
||||||
}
|
}
|
||||||
|
return found
|
||||||
// Read edge indices
|
&& ( m_table.rules == cook_rules )
|
||||||
std::fseek( file, header.edge_indices_offset, SEEK_SET );
|
&& ( m_table.side > 0.0 )
|
||||||
|
&& ( m_table.levels >= 1 );
|
||||||
Out.north_edge.resize( header.edge_count_north );
|
}
|
||||||
Out.south_edge.resize( header.edge_count_south );
|
|
||||||
Out.west_edge.resize( header.edge_count_west );
|
// Which tiles there are is worked out once. Asking the filesystem, or even the archive's index,
|
||||||
Out.east_edge.resize( header.edge_count_east );
|
// for a tile that is not there is the commonest thing the renderer does - most of a quadtree is
|
||||||
|
// empty - and it must not cost anything.
|
||||||
if( header.edge_count_north > 0 )
|
inline void
|
||||||
std::fread( Out.north_edge.data(), sizeof( std::uint16_t ), header.edge_count_north, file );
|
reader::find_tiles() {
|
||||||
if( header.edge_count_south > 0 )
|
|
||||||
std::fread( Out.south_edge.data(), sizeof( std::uint16_t ), header.edge_count_south, file );
|
auto const remember = [ this ]( std::string const &Name ) {
|
||||||
if( header.edge_count_west > 0 )
|
if( false == Name.ends_with( ".terrain" ) ) { return; }
|
||||||
std::fread( Out.west_edge.data(), sizeof( std::uint16_t ), header.edge_count_west, file );
|
tile_address tile {};
|
||||||
if( header.edge_count_east > 0 )
|
if( 3 != std::sscanf( Name.c_str(), "%u/%d/%d.terrain", &tile.level, &tile.x, &tile.z ) ) { return; }
|
||||||
std::fread( Out.east_edge.data(), sizeof( std::uint16_t ), header.edge_count_east, file );
|
if( Name != tile_name( tile ) ) { return; }
|
||||||
|
m_present.insert( tile_key( tile ) ); };
|
||||||
// Materials - for now, all zeros (placeholder)
|
|
||||||
Out.materials.resize( header.vertex_count, 0 );
|
if( true == m_archive ) {
|
||||||
|
for( auto const &name : m_pak.names() ) { remember( name ); }
|
||||||
std::fclose( file );
|
return;
|
||||||
|
}
|
||||||
|
std::error_code error;
|
||||||
|
auto const base { std::filesystem::path( m_path ) };
|
||||||
|
for( auto const &found : std::filesystem::recursive_directory_iterator( base, error ) ) {
|
||||||
|
if( false == found.is_regular_file( error ) ) { continue; }
|
||||||
|
auto const relative { std::filesystem::relative( found.path(), base, error ) };
|
||||||
|
if( error ) { continue; }
|
||||||
|
remember( relative.generic_string() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool
|
||||||
|
reader::read_tile( tile_address const &Tile, tile_data &Out,
|
||||||
|
double const Originx, double const Originy, double const Originz ) {
|
||||||
|
|
||||||
|
if( false == m_ready ) { return false; }
|
||||||
|
if( Tile.level >= m_table.levels ) { return false; }
|
||||||
|
if( false == entry( tile_name( Tile ), m_buffer ) ) { return false; }
|
||||||
|
|
||||||
|
auto const *cursor { m_buffer.data() };
|
||||||
|
auto const *end { cursor + m_buffer.size() };
|
||||||
|
auto const take = [ &cursor, end ]( void *Into, std::size_t const Bytes ) {
|
||||||
|
if( static_cast<std::size_t>( end - cursor ) < Bytes ) { return false; }
|
||||||
|
std::memcpy( Into, cursor, Bytes );
|
||||||
|
cursor += Bytes;
|
||||||
|
return true; };
|
||||||
|
|
||||||
|
file_header header {};
|
||||||
|
if( false == take( &header, sizeof( header ) ) ) { return false; }
|
||||||
|
|
||||||
|
std::uint32_t vertexcount { 0 };
|
||||||
|
if( false == take( &vertexcount, sizeof( vertexcount ) ) ) { return false; }
|
||||||
|
if( ( vertexcount < 3 ) || ( vertexcount > ( 1u << 24 ) ) ) { return false; }
|
||||||
|
|
||||||
|
// the three runs of zigzag deltas: x, z, then height
|
||||||
|
std::vector<std::uint16_t> quantized[ 3 ];
|
||||||
|
for( auto &run : quantized ) {
|
||||||
|
run.resize( vertexcount );
|
||||||
|
std::int32_t value { 0 };
|
||||||
|
for( auto &stored : run ) {
|
||||||
|
std::uint16_t delta { 0 };
|
||||||
|
if( false == take( &delta, sizeof( delta ) ) ) { return false; }
|
||||||
|
value += zigzag_decode( delta );
|
||||||
|
if( ( value < 0 ) || ( value > quantum_max ) ) { return false; }
|
||||||
|
stored = static_cast<std::uint16_t>( value );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto const wide { vertexcount > 65536 };
|
||||||
|
auto const indexsize { wide ? 4u : 2u };
|
||||||
|
while( ( static_cast<std::size_t>( cursor - m_buffer.data() ) % indexsize ) != 0 ) {
|
||||||
|
if( cursor >= end ) { return false; }
|
||||||
|
++cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t trianglecount { 0 };
|
||||||
|
if( false == take( &trianglecount, sizeof( trianglecount ) ) ) { return false; }
|
||||||
|
if( ( trianglecount == 0 ) || ( trianglecount > ( 1u << 24 ) ) ) { return false; }
|
||||||
|
|
||||||
|
auto const codes { static_cast<std::size_t>( trianglecount ) * 3 };
|
||||||
|
auto decoded { false };
|
||||||
|
if( true == wide ) {
|
||||||
|
std::vector<std::uint32_t> raw( codes );
|
||||||
|
for( auto &code : raw ) { if( false == take( &code, sizeof( code ) ) ) { return false; } }
|
||||||
|
decoded = highwater_decode( raw, Out.indices );
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
std::vector<std::uint16_t> raw( codes );
|
||||||
|
for( auto &code : raw ) { if( false == take( &code, sizeof( code ) ) ) { return false; } }
|
||||||
|
decoded = highwater_decode( raw, Out.indices );
|
||||||
|
}
|
||||||
|
if( false == decoded ) { return false; }
|
||||||
|
for( auto const index : Out.indices ) {
|
||||||
|
if( index >= vertexcount ) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// the four lists of border vertices. nothing here needs them - a tile's border survives into
|
||||||
|
// every level of it, so neighbours meet whatever level they are drawn at - but they are part
|
||||||
|
// of the format and are stepped over
|
||||||
|
for( int edge = 0; edge < 4; ++edge ) {
|
||||||
|
std::uint32_t count { 0 };
|
||||||
|
if( false == take( &count, sizeof( count ) ) ) { return false; }
|
||||||
|
if( count > vertexcount ) { return false; }
|
||||||
|
if( static_cast<std::size_t>( end - cursor ) < count * indexsize ) { return false; }
|
||||||
|
cursor += count * indexsize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// which material each vertex belongs to, from the extension the cook writes
|
||||||
|
std::vector<std::uint16_t> ground;
|
||||||
|
while( cursor < end ) {
|
||||||
|
std::uint8_t id { 0 };
|
||||||
|
std::uint32_t length { 0 };
|
||||||
|
if( false == take( &id, sizeof( id ) ) ) { return false; }
|
||||||
|
if( false == take( &length, sizeof( length ) ) ) { return false; }
|
||||||
|
if( static_cast<std::size_t>( end - cursor ) < length ) { return false; }
|
||||||
|
if( ( id == ext_ground ) && ( length == vertexcount * sizeof( std::uint16_t ) ) ) {
|
||||||
|
ground.resize( vertexcount );
|
||||||
|
std::memcpy( ground.data(), cursor, length );
|
||||||
|
}
|
||||||
|
cursor += length;
|
||||||
|
}
|
||||||
|
if( ground.size() != vertexcount ) { return false; }
|
||||||
|
|
||||||
|
auto const side { m_table.tilesize( Tile.level ) };
|
||||||
|
auto const west { m_table.west + Tile.x * side };
|
||||||
|
auto const north { m_table.north + Tile.z * side };
|
||||||
|
auto const heightspan { std::max( 1e-3, static_cast<double>( header.highest ) - header.lowest ) };
|
||||||
|
|
||||||
|
Out.tile = Tile;
|
||||||
|
Out.originx = Originx;
|
||||||
|
Out.originy = Originy;
|
||||||
|
Out.originz = Originz;
|
||||||
|
Out.centrex = header.sphere_x;
|
||||||
|
Out.centrey = header.sphere_y;
|
||||||
|
Out.centrez = header.sphere_z;
|
||||||
|
Out.radius = header.sphere_radius;
|
||||||
|
Out.lowest = header.lowest;
|
||||||
|
Out.highest = header.highest;
|
||||||
|
|
||||||
|
Out.vertices.resize( vertexcount );
|
||||||
|
for( std::uint32_t index = 0; index < vertexcount; ++index ) {
|
||||||
|
auto &vertex { Out.vertices[ index ] };
|
||||||
|
vertex.x = static_cast<float>( dequantize( quantized[ 0 ][ index ], west, side ) - Out.originx );
|
||||||
|
vertex.y = static_cast<float>( dequantize( quantized[ 2 ][ index ], header.lowest, heightspan ) - Out.originy );
|
||||||
|
vertex.z = static_cast<float>( dequantize( quantized[ 1 ][ index ], north, side ) - Out.originz );
|
||||||
|
vertex.material = ground[ index ];
|
||||||
|
vertex.padding = 0;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writes the table the reader expects, so that the cook and the reader cannot disagree about it
|
||||||
|
inline std::vector<std::uint8_t>
|
||||||
|
write_table( terrain_table const &Table ) {
|
||||||
|
|
||||||
|
auto const number = []( double const Value ) {
|
||||||
|
char text[ 64 ];
|
||||||
|
std::snprintf( text, sizeof( text ), "%.6f", Value );
|
||||||
|
return std::string { text }; };
|
||||||
|
|
||||||
|
std::string text { "eu07-terrain 1\n" };
|
||||||
|
text += "rules " + std::to_string( Table.rules ) + "\n";
|
||||||
|
text += "square " + number( Table.west ) + " " + number( Table.north ) + " " + number( Table.side ) + "\n";
|
||||||
|
text += "height " + number( Table.lowest ) + " " + number( Table.highest ) + "\n";
|
||||||
|
text += "levels " + std::to_string( Table.levels ) + "\n";
|
||||||
|
for( std::size_t level = 0; level < Table.errors.size(); ++level ) {
|
||||||
|
text += "error " + std::to_string( level ) + " " + number( Table.errors[ level ] ) + "\n";
|
||||||
|
}
|
||||||
|
for( auto const &material : Table.materials ) {
|
||||||
|
text += "material " + material.name + " " + number( material.repeat ) + "\n";
|
||||||
|
}
|
||||||
|
return { text.begin(), text.end() };
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace quantizedmesh
|
} // namespace quantizedmesh
|
||||||
|
|||||||
@@ -1,579 +0,0 @@
|
|||||||
/*
|
|
||||||
This Source Code Form is subject to the
|
|
||||||
terms of the Mozilla Public License, v.
|
|
||||||
2.0. If a copy of the MPL was not
|
|
||||||
distributed with this file, You can
|
|
||||||
obtain one at
|
|
||||||
http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "scene/terraincooker_tin.h"
|
|
||||||
#include "scene/terraincooker.h"
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <cassert>
|
|
||||||
#include <cmath>
|
|
||||||
#include <fstream>
|
|
||||||
#include <iostream>
|
|
||||||
#include <queue>
|
|
||||||
#include <unordered_set>
|
|
||||||
|
|
||||||
namespace terrain {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// Hash for int64 tile key
|
|
||||||
struct tile_key_hash {
|
|
||||||
std::size_t operator()( std::int64_t key ) const noexcept {
|
|
||||||
return static_cast<std::size_t>( key ^ ( key >> 32 ) );
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate tile coordinates from world position
|
|
||||||
std::int32_t tile_coord( double world_pos, double tile_size ) {
|
|
||||||
return static_cast<std::int32_t>( std::floor( world_pos / tile_size ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
std::int64_t make_tile_key( std::int32_t x, std::int32_t z ) {
|
|
||||||
return ( static_cast<std::int64_t>( x ) << 32 ) | static_cast<std::uint32_t>( z );
|
|
||||||
}
|
|
||||||
|
|
||||||
} // anonymous namespace
|
|
||||||
|
|
||||||
void tin_cooker::configure(
|
|
||||||
double TileSize,
|
|
||||||
std::uint32_t LodLevels,
|
|
||||||
double ErrorMultiplier
|
|
||||||
) {
|
|
||||||
m_tilesize = TileSize;
|
|
||||||
m_lodlevels = std::max( 1u, LodLevels );
|
|
||||||
m_errormultiplier = std::max( 1.1, ErrorMultiplier );
|
|
||||||
}
|
|
||||||
|
|
||||||
void tin_cooker::rasterize(
|
|
||||||
vertex const &A,
|
|
||||||
vertex const &B,
|
|
||||||
vertex const &C,
|
|
||||||
std::string_view Material
|
|
||||||
) {
|
|
||||||
++m_triangles_in;
|
|
||||||
|
|
||||||
// Find centroid to determine tile
|
|
||||||
double const cx = ( A.x + B.x + C.x ) / 3.0;
|
|
||||||
double const cz = ( A.z + B.z + C.z ) / 3.0;
|
|
||||||
|
|
||||||
std::int32_t const tile_x = tile_coord( cx, m_tilesize );
|
|
||||||
std::int32_t const tile_z = tile_coord( cz, m_tilesize );
|
|
||||||
|
|
||||||
std::int64_t const key = make_tile_key( tile_x, tile_z );
|
|
||||||
|
|
||||||
auto &tile = m_tiles[ key ];
|
|
||||||
tile.triangles.push_back( { { A, B, C }, std::string( Material ) } );
|
|
||||||
tile.dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void tin_cooker::add_constraint( vertex const &A, vertex const &B ) {
|
|
||||||
// Add constraint to all tiles it intersects
|
|
||||||
double const minx = std::min( A.x, B.x );
|
|
||||||
double const maxx = std::max( A.x, B.x );
|
|
||||||
double const minz = std::min( A.z, B.z );
|
|
||||||
double const maxz = std::max( A.z, B.z );
|
|
||||||
|
|
||||||
std::int32_t const tile_x0 = tile_coord( minx, m_tilesize );
|
|
||||||
std::int32_t const tile_z0 = tile_coord( minz, m_tilesize );
|
|
||||||
std::int32_t const tile_x1 = tile_coord( maxx, m_tilesize );
|
|
||||||
std::int32_t const tile_z1 = tile_coord( maxz, m_tilesize );
|
|
||||||
|
|
||||||
for( std::int32_t tx = tile_x0; tx <= tile_x1; ++tx ) {
|
|
||||||
for( std::int32_t tz = tile_z0; tz <= tile_z1; ++tz ) {
|
|
||||||
std::int64_t const key = make_tile_key( tx, tz );
|
|
||||||
auto &tile = m_tiles[ key ];
|
|
||||||
tile.constraints.push_back( { A, B } );
|
|
||||||
tile.dirty = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<tin_tile> tin_cooker::finish() {
|
|
||||||
std::vector<tin_tile> result;
|
|
||||||
result.reserve( m_tiles.size() );
|
|
||||||
|
|
||||||
std::size_t total_tiles = m_tiles.size();
|
|
||||||
std::size_t processed = 0;
|
|
||||||
|
|
||||||
for( auto &[ key, accumulator ] : m_tiles ) {
|
|
||||||
if( !accumulator.dirty || accumulator.triangles.empty() ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
++processed;
|
|
||||||
if( processed % 100 == 0 || processed == total_tiles ) {
|
|
||||||
std::printf( "\rTriangulating tiles: %zu/%zu", processed, total_tiles );
|
|
||||||
std::fflush( stdout );
|
|
||||||
}
|
|
||||||
|
|
||||||
std::int32_t const tile_x = static_cast<std::int32_t>( key >> 32 );
|
|
||||||
std::int32_t const tile_z = static_cast<std::int32_t>( key & 0xffffffff );
|
|
||||||
|
|
||||||
tin_tile tile;
|
|
||||||
tile.x = tile_x;
|
|
||||||
tile.z = tile_z;
|
|
||||||
|
|
||||||
// Tile bounds
|
|
||||||
tile.minx = tile_x * m_tilesize;
|
|
||||||
tile.minz = tile_z * m_tilesize;
|
|
||||||
tile.maxx = tile.minx + m_tilesize;
|
|
||||||
tile.maxz = tile.minz + m_tilesize;
|
|
||||||
tile.miny = std::numeric_limits<double>::max();
|
|
||||||
tile.maxy = std::numeric_limits<double>::lowest();
|
|
||||||
|
|
||||||
// Collect unique vertices from triangles
|
|
||||||
std::vector<vertex> points;
|
|
||||||
points.reserve( accumulator.triangles.size() * 3 );
|
|
||||||
|
|
||||||
for( auto const &tri : accumulator.triangles ) {
|
|
||||||
for( auto const &v : tri.v ) {
|
|
||||||
points.push_back( v );
|
|
||||||
tile.miny = std::min( tile.miny, v.y );
|
|
||||||
tile.maxy = std::max( tile.maxy, v.y );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build material table
|
|
||||||
for( auto const &tri : accumulator.triangles ) {
|
|
||||||
get_material_index( tile, tri.material );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate LODs from finest to coarsest
|
|
||||||
double current_error = 0.1; // Start with very fine detail
|
|
||||||
|
|
||||||
for( std::uint32_t lod = 0; lod < m_lodlevels; ++lod ) {
|
|
||||||
tin_lod level;
|
|
||||||
|
|
||||||
if( lod == 0 ) {
|
|
||||||
// Finest LOD: triangulate all points with constraints
|
|
||||||
level = triangulate_lod( points, accumulator.constraints, current_error );
|
|
||||||
} else {
|
|
||||||
// Coarser LODs: simplify previous level
|
|
||||||
level = simplify_lod( tile.lods.back(), current_error );
|
|
||||||
}
|
|
||||||
|
|
||||||
level.geometric_error = current_error;
|
|
||||||
extract_edges( level, tile.minx, tile.minz, tile.maxx, tile.maxz );
|
|
||||||
|
|
||||||
tile.lods.push_back( std::move( level ) );
|
|
||||||
|
|
||||||
m_triangles_out += level.indices.size() / 3;
|
|
||||||
m_vertices_out += level.vertices.size();
|
|
||||||
|
|
||||||
current_error *= m_errormultiplier;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push_back( std::move( tile ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
std::printf( "\n" );
|
|
||||||
std::printf( "Triangulation complete:\n" );
|
|
||||||
std::printf( " Input triangles: %zu\n", m_triangles_in );
|
|
||||||
std::printf( " Output triangles: %zu\n", m_triangles_out );
|
|
||||||
std::printf( " Output vertices: %zu\n", m_vertices_out );
|
|
||||||
std::printf( " Reduction: %.1f%%\n",
|
|
||||||
m_triangles_in > 0 ? 100.0 * ( 1.0 - static_cast<double>( m_triangles_out ) / m_triangles_in ) : 0.0 );
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
tin_lod tin_cooker::triangulate_lod(
|
|
||||||
std::vector<vertex> const &Points,
|
|
||||||
std::vector<tin_constraint> const &Constraints,
|
|
||||||
double TargetError
|
|
||||||
) {
|
|
||||||
tin_lod result;
|
|
||||||
|
|
||||||
if( Points.empty() ) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert points to CDT format, removing duplicates
|
|
||||||
std::vector<CDT::V2d<double>> cdt_points;
|
|
||||||
std::vector<CDT::Edge> cdt_edges;
|
|
||||||
|
|
||||||
cdt_points.reserve( Points.size() );
|
|
||||||
std::unordered_map<std::uint64_t, std::uint32_t> point_map;
|
|
||||||
|
|
||||||
auto hash_point = []( vertex const &v ) -> std::uint64_t {
|
|
||||||
// Hash to 1cm precision
|
|
||||||
std::uint32_t const x = static_cast<std::uint32_t>( std::round( v.x * 100.0 ) );
|
|
||||||
std::uint32_t const z = static_cast<std::uint32_t>( std::round( v.z * 100.0 ) );
|
|
||||||
return ( static_cast<std::uint64_t>( x ) << 32 ) | z;
|
|
||||||
};
|
|
||||||
|
|
||||||
for( auto const &v : Points ) {
|
|
||||||
std::uint64_t const h = hash_point( v );
|
|
||||||
auto const [ it, inserted ] = point_map.try_emplace( h, static_cast<std::uint32_t>( cdt_points.size() ) );
|
|
||||||
|
|
||||||
if( inserted ) {
|
|
||||||
cdt_points.push_back( to_cdt_point( v ) );
|
|
||||||
result.vertices.push_back( v );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add constraint edges
|
|
||||||
for( auto const &constraint : Constraints ) {
|
|
||||||
std::uint64_t const h0 = hash_point( constraint.a );
|
|
||||||
std::uint64_t const h1 = hash_point( constraint.b );
|
|
||||||
|
|
||||||
auto it0 = point_map.find( h0 );
|
|
||||||
auto it1 = point_map.find( h1 );
|
|
||||||
|
|
||||||
if( it0 != point_map.end() && it1 != point_map.end() && it0->second != it1->second ) {
|
|
||||||
cdt_edges.push_back( CDT::Edge( it0->second, it1->second ) );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run CDT
|
|
||||||
CDT::Triangulation<double> cdt;
|
|
||||||
cdt.insertVertices( cdt_points );
|
|
||||||
|
|
||||||
if( !cdt_edges.empty() ) {
|
|
||||||
cdt.insertEdges( cdt_edges );
|
|
||||||
cdt.eraseOuterTrianglesAndHoles();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract triangles
|
|
||||||
result.indices.reserve( cdt.triangles.size() * 3 );
|
|
||||||
|
|
||||||
for( auto const &tri : cdt.triangles ) {
|
|
||||||
result.indices.push_back( tri.vertices[ 0 ] );
|
|
||||||
result.indices.push_back( tri.vertices[ 1 ] );
|
|
||||||
result.indices.push_back( tri.vertices[ 2 ] );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign material indices (simplified - use most common material for now)
|
|
||||||
result.materials.resize( result.vertices.size(), 0 );
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
tin_lod tin_cooker::simplify_lod(
|
|
||||||
tin_lod const &FineLod,
|
|
||||||
double TargetError
|
|
||||||
) {
|
|
||||||
// Edge collapse simplification
|
|
||||||
// For now: simple vertex removal based on geometric error
|
|
||||||
|
|
||||||
tin_lod result = FineLod; // Start with copy
|
|
||||||
|
|
||||||
if( FineLod.vertices.size() < 100 ) {
|
|
||||||
// Too small to simplify meaningfully
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build adjacency information
|
|
||||||
std::unordered_map<std::uint32_t, std::vector<std::uint32_t>> adjacency;
|
|
||||||
|
|
||||||
for( std::size_t i = 0; i < FineLod.indices.size(); i += 3 ) {
|
|
||||||
std::uint32_t const i0 = FineLod.indices[ i + 0 ];
|
|
||||||
std::uint32_t const i1 = FineLod.indices[ i + 1 ];
|
|
||||||
std::uint32_t const i2 = FineLod.indices[ i + 2 ];
|
|
||||||
|
|
||||||
adjacency[ i0 ].push_back( i1 );
|
|
||||||
adjacency[ i0 ].push_back( i2 );
|
|
||||||
adjacency[ i1 ].push_back( i0 );
|
|
||||||
adjacency[ i1 ].push_back( i2 );
|
|
||||||
adjacency[ i2 ].push_back( i0 );
|
|
||||||
adjacency[ i2 ].push_back( i1 );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate error for each vertex
|
|
||||||
struct vertex_error {
|
|
||||||
std::uint32_t index;
|
|
||||||
double error;
|
|
||||||
|
|
||||||
bool operator>( vertex_error const &other ) const {
|
|
||||||
return error > other.error; // Min-heap
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
std::priority_queue<vertex_error, std::vector<vertex_error>, std::greater<vertex_error>> error_queue;
|
|
||||||
std::unordered_set<std::uint32_t> removed;
|
|
||||||
|
|
||||||
for( std::uint32_t i = 0; i < FineLod.vertices.size(); ++i ) {
|
|
||||||
auto it = adjacency.find( i );
|
|
||||||
if( it == adjacency.end() || it->second.empty() ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<vertex> neighbours;
|
|
||||||
for( auto const j : it->second ) {
|
|
||||||
neighbours.push_back( FineLod.vertices[ j ] );
|
|
||||||
}
|
|
||||||
|
|
||||||
double const error = calculate_error( FineLod.vertices[ i ], neighbours );
|
|
||||||
|
|
||||||
if( error < TargetError ) {
|
|
||||||
error_queue.push( { i, error } );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove vertices with low error (target: remove ~30%)
|
|
||||||
std::size_t const target_removals = FineLod.vertices.size() * 3 / 10;
|
|
||||||
|
|
||||||
while( !error_queue.empty() && removed.size() < target_removals ) {
|
|
||||||
auto const top = error_queue.top();
|
|
||||||
error_queue.pop();
|
|
||||||
|
|
||||||
if( removed.count( top.index ) > 0 ) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
removed.insert( top.index );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild mesh without removed vertices
|
|
||||||
std::unordered_map<std::uint32_t, std::uint32_t> index_remap;
|
|
||||||
std::vector<vertex> new_vertices;
|
|
||||||
std::vector<std::uint8_t> new_materials;
|
|
||||||
|
|
||||||
for( std::uint32_t i = 0; i < FineLod.vertices.size(); ++i ) {
|
|
||||||
if( removed.count( i ) == 0 ) {
|
|
||||||
index_remap[ i ] = static_cast<std::uint32_t>( new_vertices.size() );
|
|
||||||
new_vertices.push_back( FineLod.vertices[ i ] );
|
|
||||||
if( i < FineLod.materials.size() ) {
|
|
||||||
new_materials.push_back( FineLod.materials[ i ] );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.vertices = std::move( new_vertices );
|
|
||||||
result.materials = std::move( new_materials );
|
|
||||||
|
|
||||||
// Re-triangulate (simple approach: use CDT again)
|
|
||||||
std::vector<CDT::V2d<double>> cdt_points;
|
|
||||||
cdt_points.reserve( result.vertices.size() );
|
|
||||||
|
|
||||||
for( auto const &v : result.vertices ) {
|
|
||||||
cdt_points.push_back( to_cdt_point( v ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
CDT::Triangulation<double> cdt;
|
|
||||||
cdt.insertVertices( cdt_points );
|
|
||||||
|
|
||||||
result.indices.clear();
|
|
||||||
result.indices.reserve( cdt.triangles.size() * 3 );
|
|
||||||
|
|
||||||
for( auto const &tri : cdt.triangles ) {
|
|
||||||
result.indices.push_back( tri.vertices[ 0 ] );
|
|
||||||
result.indices.push_back( tri.vertices[ 1 ] );
|
|
||||||
result.indices.push_back( tri.vertices[ 2 ] );
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
double tin_cooker::calculate_error(
|
|
||||||
vertex const &V,
|
|
||||||
std::vector<vertex> const &Neighbours
|
|
||||||
) {
|
|
||||||
if( Neighbours.size() < 3 ) {
|
|
||||||
return std::numeric_limits<double>::max();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate maximum distance to plane fitted through neighbours
|
|
||||||
// Simplified: just use average height difference
|
|
||||||
|
|
||||||
double sum_y = 0.0;
|
|
||||||
for( auto const &n : Neighbours ) {
|
|
||||||
sum_y += n.y;
|
|
||||||
}
|
|
||||||
double const avg_y = sum_y / Neighbours.size();
|
|
||||||
|
|
||||||
return std::abs( V.y - avg_y );
|
|
||||||
}
|
|
||||||
|
|
||||||
void tin_cooker::extract_edges(
|
|
||||||
tin_lod &Lod,
|
|
||||||
double TileMinX,
|
|
||||||
double TileMinZ,
|
|
||||||
double TileMaxX,
|
|
||||||
double TileMaxZ
|
|
||||||
) {
|
|
||||||
constexpr double edge_epsilon = 0.01;
|
|
||||||
|
|
||||||
Lod.north_edge.clear();
|
|
||||||
Lod.south_edge.clear();
|
|
||||||
Lod.west_edge.clear();
|
|
||||||
Lod.east_edge.clear();
|
|
||||||
|
|
||||||
for( std::uint32_t i = 0; i < Lod.vertices.size(); ++i ) {
|
|
||||||
auto const &v = Lod.vertices[ i ];
|
|
||||||
|
|
||||||
bool on_edge = false;
|
|
||||||
|
|
||||||
if( std::abs( v.x - TileMinX ) < edge_epsilon ) {
|
|
||||||
Lod.west_edge.push_back( static_cast<std::uint16_t>( i ) );
|
|
||||||
on_edge = true;
|
|
||||||
}
|
|
||||||
if( std::abs( v.x - TileMaxX ) < edge_epsilon ) {
|
|
||||||
Lod.east_edge.push_back( static_cast<std::uint16_t>( i ) );
|
|
||||||
on_edge = true;
|
|
||||||
}
|
|
||||||
if( std::abs( v.z - TileMinZ ) < edge_epsilon ) {
|
|
||||||
Lod.south_edge.push_back( static_cast<std::uint16_t>( i ) );
|
|
||||||
on_edge = true;
|
|
||||||
}
|
|
||||||
if( std::abs( v.z - TileMaxZ ) < edge_epsilon ) {
|
|
||||||
Lod.north_edge.push_back( static_cast<std::uint16_t>( i ) );
|
|
||||||
on_edge = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort edges by position (west/east by Z, north/south by X)
|
|
||||||
auto sort_west_east = [ &Lod ]( std::uint16_t a, std::uint16_t b ) {
|
|
||||||
return Lod.vertices[ a ].z < Lod.vertices[ b ].z;
|
|
||||||
};
|
|
||||||
auto sort_north_south = [ &Lod ]( std::uint16_t a, std::uint16_t b ) {
|
|
||||||
return Lod.vertices[ a ].x < Lod.vertices[ b ].x;
|
|
||||||
};
|
|
||||||
|
|
||||||
std::sort( Lod.west_edge.begin(), Lod.west_edge.end(), sort_west_east );
|
|
||||||
std::sort( Lod.east_edge.begin(), Lod.east_edge.end(), sort_west_east );
|
|
||||||
std::sort( Lod.north_edge.begin(), Lod.north_edge.end(), sort_north_south );
|
|
||||||
std::sort( Lod.south_edge.begin(), Lod.south_edge.end(), sort_north_south );
|
|
||||||
}
|
|
||||||
|
|
||||||
std::uint8_t tin_cooker::get_material_index( tin_tile &Tile, std::string const &Material ) {
|
|
||||||
auto it = Tile.material_lookup.find( Material );
|
|
||||||
|
|
||||||
if( it != Tile.material_lookup.end() ) {
|
|
||||||
return it->second;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::uint8_t const index = static_cast<std::uint8_t>( Tile.materials.size() );
|
|
||||||
Tile.materials.push_back( Material );
|
|
||||||
Tile.material_lookup[ Material ] = index;
|
|
||||||
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool tin_cooker::write_tile(
|
|
||||||
tin_tile const &Tile,
|
|
||||||
std::uint32_t LodIndex,
|
|
||||||
std::string const &Path,
|
|
||||||
bool Compress
|
|
||||||
) {
|
|
||||||
if( LodIndex >= Tile.lods.size() ) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto const &lod = Tile.lods[ LodIndex ];
|
|
||||||
|
|
||||||
// Calculate bounding box centre
|
|
||||||
double const cx = ( Tile.minx + Tile.maxx ) * 0.5;
|
|
||||||
double const cy = ( Tile.miny + Tile.maxy ) * 0.5;
|
|
||||||
double const cz = ( Tile.minz + Tile.maxz ) * 0.5;
|
|
||||||
|
|
||||||
// Build header
|
|
||||||
quantizedmesh::file_header header{};
|
|
||||||
std::memcpy( header.magic, quantizedmesh::magic, sizeof( header.magic ) );
|
|
||||||
header.version = quantizedmesh::version;
|
|
||||||
|
|
||||||
header.center_x = cx;
|
|
||||||
header.center_y = cy;
|
|
||||||
header.center_z = cz;
|
|
||||||
|
|
||||||
header.min_x = Tile.minx;
|
|
||||||
header.min_y = Tile.miny;
|
|
||||||
header.min_z = Tile.minz;
|
|
||||||
|
|
||||||
header.max_x = Tile.maxx;
|
|
||||||
header.max_y = Tile.maxy;
|
|
||||||
header.max_z = Tile.maxz;
|
|
||||||
|
|
||||||
header.horizont_error = lod.geometric_error;
|
|
||||||
header.vertex_count = static_cast<std::uint32_t>( lod.vertices.size() );
|
|
||||||
header.triangle_count = static_cast<std::uint32_t>( lod.indices.size() / 3 );
|
|
||||||
|
|
||||||
header.index_size = lod.vertices.size() > 65535 ? 4 : 2;
|
|
||||||
header.edge_count_north = static_cast<std::uint16_t>( lod.north_edge.size() );
|
|
||||||
header.edge_count_south = static_cast<std::uint16_t>( lod.south_edge.size() );
|
|
||||||
header.edge_count_west = static_cast<std::uint16_t>( lod.west_edge.size() );
|
|
||||||
header.edge_count_east = static_cast<std::uint16_t>( lod.east_edge.size() );
|
|
||||||
|
|
||||||
// Calculate offsets
|
|
||||||
std::uint32_t offset = sizeof( header );
|
|
||||||
|
|
||||||
header.vertex_data_offset = offset;
|
|
||||||
offset += header.vertex_count * 3 * sizeof( std::uint16_t );
|
|
||||||
|
|
||||||
header.uv_data_offset = offset;
|
|
||||||
offset += header.vertex_count * 2 * sizeof( std::uint16_t );
|
|
||||||
|
|
||||||
header.index_data_offset = offset;
|
|
||||||
offset += header.triangle_count * 3 * header.index_size;
|
|
||||||
|
|
||||||
header.edge_indices_offset = offset;
|
|
||||||
std::uint32_t const edge_data_size =
|
|
||||||
( header.edge_count_north + header.edge_count_south +
|
|
||||||
header.edge_count_west + header.edge_count_east ) * sizeof( std::uint16_t );
|
|
||||||
offset += edge_data_size;
|
|
||||||
|
|
||||||
header.extension_offset = offset;
|
|
||||||
|
|
||||||
// Write file
|
|
||||||
std::ofstream file( Path, std::ios::binary );
|
|
||||||
if( !file.good() ) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
file.write( reinterpret_cast<char const *>( &header ), sizeof( header ) );
|
|
||||||
|
|
||||||
// Write quantized vertex positions
|
|
||||||
for( auto const &v : lod.vertices ) {
|
|
||||||
std::uint16_t const qx = quantizedmesh::quantize( v.x, Tile.minx, Tile.maxx );
|
|
||||||
std::uint16_t const qy = quantizedmesh::quantize( v.y, Tile.miny, Tile.maxy );
|
|
||||||
std::uint16_t const qz = quantizedmesh::quantize( v.z, Tile.minz, Tile.maxz );
|
|
||||||
|
|
||||||
file.write( reinterpret_cast<char const *>( &qx ), sizeof( qx ) );
|
|
||||||
file.write( reinterpret_cast<char const *>( &qy ), sizeof( qy ) );
|
|
||||||
file.write( reinterpret_cast<char const *>( &qz ), sizeof( qz ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write UV coordinates (from texture coords in vertex)
|
|
||||||
for( auto const &v : lod.vertices ) {
|
|
||||||
std::uint16_t const u = quantizedmesh::quantize( v.u, 0.0, 1.0 );
|
|
||||||
std::uint16_t const uv = quantizedmesh::quantize( v.v, 0.0, 1.0 );
|
|
||||||
|
|
||||||
file.write( reinterpret_cast<char const *>( &u ), sizeof( u ) );
|
|
||||||
file.write( reinterpret_cast<char const *>( &uv ), sizeof( uv ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write indices
|
|
||||||
for( auto const idx : lod.indices ) {
|
|
||||||
if( header.index_size == 2 ) {
|
|
||||||
std::uint16_t const idx16 = static_cast<std::uint16_t>( idx );
|
|
||||||
file.write( reinterpret_cast<char const *>( &idx16 ), sizeof( idx16 ) );
|
|
||||||
} else {
|
|
||||||
file.write( reinterpret_cast<char const *>( &idx ), sizeof( idx ) );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write edge indices
|
|
||||||
for( auto const idx : lod.north_edge ) {
|
|
||||||
file.write( reinterpret_cast<char const *>( &idx ), sizeof( idx ) );
|
|
||||||
}
|
|
||||||
for( auto const idx : lod.south_edge ) {
|
|
||||||
file.write( reinterpret_cast<char const *>( &idx ), sizeof( idx ) );
|
|
||||||
}
|
|
||||||
for( auto const idx : lod.west_edge ) {
|
|
||||||
file.write( reinterpret_cast<char const *>( &idx ), sizeof( idx ) );
|
|
||||||
}
|
|
||||||
for( auto const idx : lod.east_edge ) {
|
|
||||||
file.write( reinterpret_cast<char const *>( &idx ), sizeof( idx ) );
|
|
||||||
}
|
|
||||||
|
|
||||||
file.close();
|
|
||||||
return file.good();
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace terrain
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
/*
|
|
||||||
This Source Code Form is subject to the
|
|
||||||
terms of the Mozilla Public License, v.
|
|
||||||
2.0. If a copy of the MPL was not
|
|
||||||
distributed with this file, You can
|
|
||||||
obtain one at
|
|
||||||
http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
// Triangulates terrain into TIN with multiple LODs using Constrained Delaunay.
|
|
||||||
// Input: triangle soup from scenery (same as heightfield cooker)
|
|
||||||
// Output: quantized mesh tiles with proper edge constraints for seamless LOD
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <cmath>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <limits>
|
|
||||||
#include <memory>
|
|
||||||
#include <string>
|
|
||||||
#include <string_view>
|
|
||||||
#include <unordered_map>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include <CDT.h>
|
|
||||||
|
|
||||||
#include "scene/quantizedmeshformat.h"
|
|
||||||
#include "scene/terraincooker.h" // For terrain::vertex definition
|
|
||||||
|
|
||||||
namespace terrain {
|
|
||||||
|
|
||||||
// Triangle with material
|
|
||||||
struct tin_triangle {
|
|
||||||
vertex v[ 3 ];
|
|
||||||
std::string material;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Constraint edge (e.g. track corridor boundary)
|
|
||||||
struct tin_constraint {
|
|
||||||
vertex a, b;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Single LOD level of a TIN tile
|
|
||||||
struct tin_lod {
|
|
||||||
std::vector<vertex> vertices;
|
|
||||||
std::vector<std::uint32_t> indices;
|
|
||||||
std::vector<std::uint8_t> materials; // Per-vertex material indices
|
|
||||||
|
|
||||||
// Edge vertices for seamless stitching (indices into vertices array)
|
|
||||||
std::vector<std::uint16_t> north_edge;
|
|
||||||
std::vector<std::uint16_t> south_edge;
|
|
||||||
std::vector<std::uint16_t> west_edge;
|
|
||||||
std::vector<std::uint16_t> east_edge;
|
|
||||||
|
|
||||||
double geometric_error; // Maximum error vs. finer LOD
|
|
||||||
};
|
|
||||||
|
|
||||||
// Complete TIN tile with all LODs
|
|
||||||
struct tin_tile {
|
|
||||||
std::int32_t x, z; // Tile coordinates
|
|
||||||
|
|
||||||
std::vector<tin_lod> lods; // Finest to coarsest
|
|
||||||
|
|
||||||
// Bounding box
|
|
||||||
double minx, miny, minz;
|
|
||||||
double maxx, maxy, maxz;
|
|
||||||
|
|
||||||
// Material table (shared across LODs)
|
|
||||||
std::vector<std::string> materials;
|
|
||||||
std::unordered_map<std::string, std::uint8_t> material_lookup;
|
|
||||||
};
|
|
||||||
|
|
||||||
class tin_cooker {
|
|
||||||
public:
|
|
||||||
tin_cooker() = default;
|
|
||||||
|
|
||||||
// Configure tile size and LOD parameters
|
|
||||||
void configure(
|
|
||||||
double TileSize, // Metres per tile side
|
|
||||||
std::uint32_t LodLevels = 4, // Number of LOD levels
|
|
||||||
double ErrorMultiplier = 2.0 // Geometric error growth per LOD
|
|
||||||
);
|
|
||||||
|
|
||||||
// Accumulate terrain triangles (same interface as heightfield cooker)
|
|
||||||
void rasterize(
|
|
||||||
vertex const &A,
|
|
||||||
vertex const &B,
|
|
||||||
vertex const &C,
|
|
||||||
std::string_view Material
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add constraint edges (track corridors, roads, water boundaries)
|
|
||||||
void add_constraint( vertex const &A, vertex const &B );
|
|
||||||
|
|
||||||
// Generate all tiles with LODs
|
|
||||||
std::vector<tin_tile> finish();
|
|
||||||
|
|
||||||
// Export single tile to Quantized Mesh format
|
|
||||||
static bool write_tile(
|
|
||||||
tin_tile const &Tile,
|
|
||||||
std::uint32_t LodIndex,
|
|
||||||
std::string const &Path,
|
|
||||||
bool Compress = true
|
|
||||||
);
|
|
||||||
|
|
||||||
private:
|
|
||||||
struct tile_accumulator {
|
|
||||||
std::vector<tin_triangle> triangles;
|
|
||||||
std::vector<tin_constraint> constraints;
|
|
||||||
bool dirty { false };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build single LOD level using CDT
|
|
||||||
tin_lod triangulate_lod(
|
|
||||||
std::vector<vertex> const &Points,
|
|
||||||
std::vector<tin_constraint> const &Constraints,
|
|
||||||
double TargetError
|
|
||||||
);
|
|
||||||
|
|
||||||
// Simplify LOD by removing vertices with low geometric error
|
|
||||||
tin_lod simplify_lod(
|
|
||||||
tin_lod const &FineLod,
|
|
||||||
double TargetError
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate geometric error of removing a vertex
|
|
||||||
double calculate_error(
|
|
||||||
vertex const &V,
|
|
||||||
std::vector<vertex> const &Neighbours
|
|
||||||
);
|
|
||||||
|
|
||||||
// Extract edge vertices for stitching
|
|
||||||
void extract_edges( tin_lod &Lod, double TileMinX, double TileMinZ, double TileMaxX, double TileMaxZ );
|
|
||||||
|
|
||||||
// Find material index, adding to table if new
|
|
||||||
std::uint8_t get_material_index( tin_tile &Tile, std::string const &Material );
|
|
||||||
|
|
||||||
double m_tilesize { 256.0 };
|
|
||||||
std::uint32_t m_lodlevels { 4 };
|
|
||||||
double m_errormultiplier { 2.0 };
|
|
||||||
|
|
||||||
std::unordered_map<std::int64_t, tile_accumulator> m_tiles;
|
|
||||||
|
|
||||||
// Statistics
|
|
||||||
std::size_t m_triangles_in { 0 };
|
|
||||||
std::size_t m_triangles_out { 0 };
|
|
||||||
std::size_t m_vertices_out { 0 };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: convert CDT point to our vertex
|
|
||||||
inline vertex from_cdt_point( CDT::V2d<double> const &P, double Y = 0.0 ) {
|
|
||||||
return vertex{ P.x, Y, P.y };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: convert vertex to CDT point
|
|
||||||
inline CDT::V2d<double> to_cdt_point( vertex const &V ) {
|
|
||||||
return CDT::V2d<double>::make( V.x, V.z );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: calculate distance from point to line segment
|
|
||||||
inline double point_to_segment_distance(
|
|
||||||
vertex const &Point,
|
|
||||||
vertex const &SegmentA,
|
|
||||||
vertex const &SegmentB
|
|
||||||
) {
|
|
||||||
double const dx = SegmentB.x - SegmentA.x;
|
|
||||||
double const dz = SegmentB.z - SegmentA.z;
|
|
||||||
double const len_sq = dx * dx + dz * dz;
|
|
||||||
|
|
||||||
if( len_sq < 1e-10 ) {
|
|
||||||
// Degenerate segment
|
|
||||||
double const pdx = Point.x - SegmentA.x;
|
|
||||||
double const pdz = Point.z - SegmentA.z;
|
|
||||||
return std::sqrt( pdx * pdx + pdz * pdz );
|
|
||||||
}
|
|
||||||
|
|
||||||
// Project point onto line
|
|
||||||
double const t = std::clamp(
|
|
||||||
( ( Point.x - SegmentA.x ) * dx + ( Point.z - SegmentA.z ) * dz ) / len_sq,
|
|
||||||
0.0, 1.0
|
|
||||||
);
|
|
||||||
|
|
||||||
double const proj_x = SegmentA.x + t * dx;
|
|
||||||
double const proj_z = SegmentA.z + t * dz;
|
|
||||||
|
|
||||||
double const pdx = Point.x - proj_x;
|
|
||||||
double const pdy = Point.y - ( SegmentA.y + t * ( SegmentB.y - SegmentA.y ) );
|
|
||||||
double const pdz = Point.z - proj_z;
|
|
||||||
|
|
||||||
return std::sqrt( pdx * pdx + pdy * pdy + pdz * pdz );
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace terrain
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
// TIN Terrain Fragment Shader
|
|
||||||
// Minimal material lookup, no blending for now
|
|
||||||
|
|
||||||
#version 330 core
|
|
||||||
|
|
||||||
// Input from vertex shader
|
|
||||||
in vec2 vUV;
|
|
||||||
flat in uint vMaterial;
|
|
||||||
|
|
||||||
// Uniforms
|
|
||||||
uniform sampler2DArray ground; // Material texture array
|
|
||||||
|
|
||||||
// Output
|
|
||||||
out vec4 fragColor;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
// Sample material texture
|
|
||||||
// TODO: Add blending between materials for smooth transitions
|
|
||||||
vec3 texCoord = vec3(vUV, float(vMaterial));
|
|
||||||
vec4 color = texture(ground, texCoord);
|
|
||||||
|
|
||||||
fragColor = color;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
// TIN Terrain Vertex Shader
|
|
||||||
// Replaces texture-based heightfield with vertex attributes
|
|
||||||
|
|
||||||
#version 330 core
|
|
||||||
|
|
||||||
// Vertex attributes from VBO
|
|
||||||
layout(location = 0) in vec3 aPosition; // Relative to tile origin
|
|
||||||
layout(location = 1) in vec2 aUV;
|
|
||||||
layout(location = 2) in uint aMaterial;
|
|
||||||
|
|
||||||
// Uniforms
|
|
||||||
uniform vec3 tile_offset; // Tile origin relative to camera
|
|
||||||
uniform mat4 projectionMatrix;
|
|
||||||
uniform mat4 modelViewMatrix;
|
|
||||||
|
|
||||||
// Output to fragment shader
|
|
||||||
out vec2 vUV;
|
|
||||||
flat out uint vMaterial;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
// Position is tile-relative, add offset to get camera-relative
|
|
||||||
vec3 worldPos = aPosition + tile_offset;
|
|
||||||
|
|
||||||
// Transform to clip space
|
|
||||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(worldPos, 1.0);
|
|
||||||
|
|
||||||
// Pass through to fragment shader
|
|
||||||
vUV = aUV;
|
|
||||||
vMaterial = aMaterial;
|
|
||||||
}
|
|
||||||
56
shaders/terrainmesh.frag
Normal file
56
shaders/terrainmesh.frag
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Terrain mesh, fragment stage.
|
||||||
|
//
|
||||||
|
// One triangle carries one material: the cook only welds vertices that share it, so nothing has to
|
||||||
|
// be blended here and a material boundary follows the edges the scenery drew it with. The texture
|
||||||
|
// coordinate is the world position over the material's tiling, so the ground keeps the scale it was
|
||||||
|
// authored at whatever level the tile is drawn at.
|
||||||
|
//
|
||||||
|
// The mesh carries no normals - that would be four more bytes on every vertex - so the surface
|
||||||
|
// normal comes from how the view position changes across the triangle, which is its face normal.
|
||||||
|
|
||||||
|
in vec4 f_pos;
|
||||||
|
in vec2 f_ground;
|
||||||
|
flat in uint f_material;
|
||||||
|
|
||||||
|
#include <common>
|
||||||
|
#include <apply_fog.glsl>
|
||||||
|
#include <tonemapping.glsl>
|
||||||
|
|
||||||
|
uniform sampler2DArray ground;
|
||||||
|
// per material: rgb the colour to show while it has no texture, a the metres one texture repeat
|
||||||
|
// covers - negative while there is no texture layer for it yet
|
||||||
|
uniform sampler2D materialtable;
|
||||||
|
|
||||||
|
layout(location = 0) out vec4 out_color;
|
||||||
|
#if MOTIONBLUR_ENABLED
|
||||||
|
layout(location = 1) out vec4 out_motion;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
void main()
|
||||||
|
{
|
||||||
|
vec4 entry = texelFetch( materialtable, ivec2( int( f_material ), 0 ), 0 );
|
||||||
|
vec3 albedo =
|
||||||
|
( entry.a < 0.0 )
|
||||||
|
? entry.rgb
|
||||||
|
: texture( ground, vec3( f_ground / max( entry.a, 0.01 ), float( f_material ) ) ).rgb;
|
||||||
|
|
||||||
|
vec3 normal = normalize( cross( dFdx( f_pos.xyz ), dFdy( f_pos.xyz ) ) );
|
||||||
|
// towards the camera, whichever way the derivatives came out
|
||||||
|
if( dot( normal, -f_pos.xyz ) < 0.0 ) { normal = -normal; }
|
||||||
|
|
||||||
|
// a plain lambert term against the first directional light, so that relief reads
|
||||||
|
float lambert = 0.35;
|
||||||
|
if( lights_count >= 1U && lights[ 0 ].type == LIGHT_DIR ) {
|
||||||
|
lambert = 0.35 + 0.65 * max( dot( normal, normalize( -lights[ 0 ].dir ) ), 0.0 );
|
||||||
|
}
|
||||||
|
vec3 col = albedo * lambert;
|
||||||
|
|
||||||
|
#if POSTFX_ENABLED
|
||||||
|
out_color = vec4( apply_fog( col ), 1.0 );
|
||||||
|
#else
|
||||||
|
out_color = tonemap( vec4( apply_fog( col ), 1.0 ) );
|
||||||
|
#endif
|
||||||
|
#if MOTIONBLUR_ENABLED
|
||||||
|
out_motion = vec4( 0.0 );
|
||||||
|
#endif
|
||||||
|
}
|
||||||
32
shaders/terrainmesh.vert
Normal file
32
shaders/terrainmesh.vert
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Terrain mesh, vertex stage.
|
||||||
|
//
|
||||||
|
// A vertex is a position and the ground material it belongs to, nothing else. The position is
|
||||||
|
// measured from the middle of the terrain rather than from the scenery's origin, so that the whole
|
||||||
|
// of a map fits in floats and every resident tile can live in one vertex buffer - which is what
|
||||||
|
// lets a frame of terrain be one draw call. terrainorigin is where that middle is relative to the
|
||||||
|
// camera, terrainworld where it is in the world.
|
||||||
|
//
|
||||||
|
// There are no texture coordinates: ground textures tile in world space, so the coordinate follows
|
||||||
|
// from where the vertex is and from how many metres one repeat of its material covers, which the
|
||||||
|
// cook measured from the scenery and left in the material table.
|
||||||
|
|
||||||
|
#include <common>
|
||||||
|
|
||||||
|
layout(location = 0) in vec3 in_position;
|
||||||
|
layout(location = 1) in uint in_material;
|
||||||
|
|
||||||
|
uniform vec3 terrainorigin; // the terrain's middle, relative to the camera
|
||||||
|
uniform vec2 terrainworld; // the terrain's middle, in the world
|
||||||
|
|
||||||
|
out vec4 f_pos;
|
||||||
|
out vec2 f_ground; // world position on the ground, for texturing
|
||||||
|
flat out uint f_material;
|
||||||
|
|
||||||
|
void main()
|
||||||
|
{
|
||||||
|
vec3 local = in_position + terrainorigin;
|
||||||
|
f_ground = terrainworld + in_position.xz;
|
||||||
|
f_material = in_material;
|
||||||
|
f_pos = modelview * vec4( local, 1.0 );
|
||||||
|
gl_Position = projection * f_pos;
|
||||||
|
}
|
||||||
@@ -94,10 +94,13 @@ write( std::string const &Path, std::vector<std::string> const &Files ) {
|
|||||||
entry item;
|
entry item;
|
||||||
item.path = file;
|
item.path = file;
|
||||||
if( false == hash_of( file, item.size, item.hash ) ) {
|
if( false == hash_of( file, item.size, item.hash ) ) {
|
||||||
// a file that was read a moment ago and cannot be now; the artifact is recorded as
|
// a file the scenery names and that is not there. all zeroes records that it was not
|
||||||
// depending on its absence, which any later read will contradict
|
// there, so that a later read agrees with the manifest instead of forcing a recook
|
||||||
item.size = 0;
|
item.size = 0;
|
||||||
item.hash = 0;
|
item.hash = 0;
|
||||||
|
item.time = 0;
|
||||||
|
text << item.size << ' ' << item.time << ' ' << std::hex << item.hash << std::dec << ' ' << item.path << '\n';
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
item.time = static_cast<std::int64_t>( last_modified( file ) );
|
item.time = static_cast<std::int64_t>( last_modified( file ) );
|
||||||
text << item.size << ' ' << item.time << ' ' << std::hex << item.hash << std::dec << ' ' << item.path << '\n';
|
text << item.size << ' ' << item.time << ' ' << std::hex << item.hash << std::dec << ' ' << item.path << '\n';
|
||||||
@@ -127,7 +130,16 @@ current( std::string const &Path ) {
|
|||||||
}
|
}
|
||||||
std::uintmax_t size { 0 };
|
std::uintmax_t size { 0 };
|
||||||
std::uint64_t hash { 0 };
|
std::uint64_t hash { 0 };
|
||||||
if( ( false == hash_of( item.path, size, hash ) ) || ( size != item.size ) || ( hash != item.hash ) ) {
|
if( false == hash_of( item.path, size, hash ) ) {
|
||||||
|
// not there now. a scenery may name an include that does not exist, and if it did not
|
||||||
|
// exist when the manifest was written either then nothing has changed. an empty file
|
||||||
|
// that does exist hashes to the starting value rather than to zero, so the two cannot
|
||||||
|
// be confused
|
||||||
|
if( ( item.size == 0 ) && ( item.hash == 0 ) && ( item.time == 0 ) ) { continue; }
|
||||||
|
WriteLog( "Cooked data: \"" + item.path + "\" is gone since \"" + Path + "\" was written" );
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if( ( size != item.size ) || ( hash != item.hash ) ) {
|
||||||
WriteLog( "Cooked data: \"" + item.path + "\" has changed since \"" + Path + "\" was written" );
|
WriteLog( "Cooked data: \"" + item.path + "\" has changed since \"" + Path + "\" was written" );
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -725,16 +725,17 @@ state_serializer::deserialize_node( cParser &Input, scene::scratch_data &Scratch
|
|||||||
// the bake sees the node exactly where it is drawn. placing is done on a copy:
|
// the bake sees the node exactly where it is drawn. placing is done on a copy:
|
||||||
// the region applies the same placement to the shape itself when inserting it
|
// the region applies the same placement to the shape itself when inserting it
|
||||||
auto const &data { shape.data() };
|
auto const &data { shape.data() };
|
||||||
auto const material {
|
auto const *entry {
|
||||||
data.material != null_handle
|
data.material != null_handle ? GfxRenderer->Material( data.material ) : nullptr };
|
||||||
? GfxRenderer->Material( data.material )->GetName()
|
auto const material { entry != nullptr ? entry->GetName() : "none" };
|
||||||
: "none" };
|
// a texture with an alpha channel is not ground; the bake wants to know
|
||||||
|
auto const translucent { ( entry != nullptr ) && entry->is_translucent() };
|
||||||
// the old switch trackbeds are dropped from the scenery when generated ones
|
// the old switch trackbeds are dropped from the scenery when generated ones
|
||||||
// replace them, so they are no part of the ground either
|
// replace them, so they are no part of the ground either
|
||||||
if( false == ( Global.CreateSwitchTrackbeds && scene::is_switch_trackbed( material ) ) ) {
|
if( false == ( Global.CreateSwitchTrackbeds && scene::is_switch_trackbed( material ) ) ) {
|
||||||
auto placed { data.vertices };
|
auto placed { data.vertices };
|
||||||
scene::place_in_world( placed, Scratchpad );
|
scene::place_in_world( placed, Scratchpad );
|
||||||
terrainbake::add( decision.key, placed, material );
|
terrainbake::add( decision.key, placed, material, translucent );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if( decision.plan.what == terrainbake::verdict::filter ) {
|
if( decision.plan.what == terrainbake::verdict::filter ) {
|
||||||
|
|||||||
@@ -9,14 +9,11 @@ http://mozilla.org/MPL/2.0/.
|
|||||||
|
|
||||||
module;
|
module;
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
|
||||||
#include <cmath>
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <memory>
|
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
@@ -24,286 +21,176 @@ module;
|
|||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include "utilities/Globals_macros.h"
|
#include "utilities/Globals_macros.h"
|
||||||
#include "scene/terraincooker.h"
|
#include "scene/pakformat.h"
|
||||||
#include "scene/heightfieldreader.h"
|
#include "scene/quantizedmeshcooker.h"
|
||||||
|
#include "scene/quantizedmeshreader.h"
|
||||||
|
|
||||||
module eu07.simulation.terrainbake;
|
module eu07.simulation.terrainbake;
|
||||||
import eu07.utilities.logs;
|
import eu07.utilities.logs;
|
||||||
import eu07.utilities.globals;
|
import eu07.utilities.globals;
|
||||||
import eu07.utilities.utilities;
|
import eu07.utilities.utilities;
|
||||||
import eu07.simulation.loadprofile;
|
import eu07.model.vertex;
|
||||||
import eu07.simulation.cookdeps;
|
import eu07.simulation.cookdeps;
|
||||||
|
|
||||||
namespace simulation::terrainbake {
|
namespace simulation::terrainbake {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// vertices kept from the start of the scenery to tell the grid step from
|
// the most triangles a tile of any level is allowed. the finest level is the ground as the scenery
|
||||||
constexpr std::size_t samplegoal { 200000 };
|
// drew it and is not cut down, so this is a ceiling for the coarser ones rather than a target
|
||||||
|
constexpr std::size_t tilebudget { 8192 };
|
||||||
|
|
||||||
// Terrain comes in two kinds: modelled by hand with triangles hundreds of metres long, or taken
|
// the note written beside the tiles: which nodes they stand for
|
||||||
// from an elevation model with small triangles along the line. One grid step for both either
|
constexpr char notemagic[ 8 ] { 'E', 'U', '0', '7', 'T', 'N', 'O', 'D' };
|
||||||
// loses the detail or samples a field of large triangles every metre. So a bake writes several
|
constexpr std::uint32_t noteversion { 1 };
|
||||||
// heightfields at steps a factor apart. A triangle's own field is the coarsest that still puts
|
// a node with no mask: the tiles stand for every triangle of it
|
||||||
// samplesperedge samples along its longest edge, and it is laid into that field and every
|
|
||||||
// coarser one: the coarsest field then holds all the ground, as the layers of a level of
|
|
||||||
// detail scheme do, and a triangle at the edge of a finer field - next to ground that field
|
|
||||||
// does not hold - can still be replaced by a coarser one lying under it. Where two fields
|
|
||||||
// hold the same ground the finer one wins the depth test. A flat triangle sampled coarsely is
|
|
||||||
// the same plane; what a coarser step gives up is the exact line of the creases between
|
|
||||||
// triangles, which a hand modelled terrain does not have to spare anyway.
|
|
||||||
constexpr std::uint32_t fieldcount { 3 };
|
|
||||||
constexpr double fieldstride { 4.0 };
|
|
||||||
constexpr double samplesperedge { 16.0 };
|
|
||||||
|
|
||||||
// the node table written beside a baked heightfield
|
|
||||||
constexpr char tablemagic[ 8 ] { 'E', 'U', '0', '7', 'H', 'F', 'N', 'D' };
|
|
||||||
// 2: nodes keyed by file, line, include parameters and placement rather than by their text
|
|
||||||
constexpr std::uint32_t tableversion { 2 };
|
|
||||||
constexpr std::uint32_t allshown { 0xffffffffu };
|
constexpr std::uint32_t allshown { 0xffffffffu };
|
||||||
|
|
||||||
struct table_header {
|
struct note_header {
|
||||||
char magic[ 8 ];
|
char magic[ 8 ];
|
||||||
std::uint32_t version;
|
std::uint32_t version;
|
||||||
std::uint32_t selection;
|
std::uint32_t rules;
|
||||||
std::uint64_t count;
|
std::uint64_t count;
|
||||||
std::uint64_t maskbytes;
|
std::uint64_t maskbytes;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct table_record {
|
struct note_record {
|
||||||
std::uint64_t key;
|
std::uint64_t key;
|
||||||
std::uint32_t triangles;
|
std::uint32_t triangles;
|
||||||
// offset into the mask blob, one bit per triangle set where it is still drawn; allshown
|
// where this node's bits start in the mask blob, one per triangle, set where the scenery
|
||||||
// when the heightfield shows every triangle and there is no mask
|
// still draws it. allshown when none of them is drawn any more
|
||||||
std::uint32_t mask;
|
std::uint32_t mask;
|
||||||
};
|
};
|
||||||
|
|
||||||
// one triangle as it waits in the spill file for the passes: its corners in world space with
|
// one node as it went into the cook: where its triangles sit in the cook's numbering
|
||||||
// their texture coordinates, the node it came from, which holds its material, and its number
|
|
||||||
struct spilled {
|
|
||||||
double corners[ 15 ];
|
|
||||||
std::uint32_t node;
|
|
||||||
std::uint32_t id;
|
|
||||||
};
|
|
||||||
|
|
||||||
// one node as baked: where its triangles sit in the cooker's numbering
|
|
||||||
struct baked_node {
|
struct baked_node {
|
||||||
std::uint64_t key;
|
std::uint64_t key { 0 };
|
||||||
std::uint32_t first;
|
std::uint32_t first { 0 };
|
||||||
std::uint32_t count;
|
std::uint32_t count { 0 };
|
||||||
// kept once per node rather than once per spilled triangle
|
|
||||||
std::string material;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
struct {
|
struct {
|
||||||
// baking. triangles go to a spill file as the scenery is parsed; the heightfield is made
|
// cooking
|
||||||
// from it once everything is in
|
|
||||||
bool collecting { false };
|
bool collecting { false };
|
||||||
std::ofstream spill;
|
quantizedmesh::cooker cooker;
|
||||||
std::string spillpath;
|
|
||||||
std::vector<terrain::vertex> sample;
|
|
||||||
std::vector<baked_node> nodes;
|
std::vector<baked_node> nodes;
|
||||||
std::uint32_t triangles { 0 };
|
std::uint32_t triangles { 0 };
|
||||||
// per triangle, once baked: whether the heightfield shows it
|
std::size_t objects { 0 };
|
||||||
std::vector<std::uint8_t> shown;
|
std::size_t objecttriangles { 0 };
|
||||||
std::chrono::steady_clock::time_point started;
|
std::chrono::steady_clock::time_point started;
|
||||||
// planning
|
// reading a cook from an earlier run
|
||||||
std::unordered_map<std::uint64_t, table_record> table;
|
std::unordered_map<std::uint64_t, note_record> note;
|
||||||
std::vector<std::uint8_t> masks;
|
std::vector<std::uint8_t> masks;
|
||||||
std::size_t skipped { 0 }, filtered { 0 }, drawn { 0 };
|
std::size_t skipped { 0 }, filtered { 0 }, drawn { 0 };
|
||||||
} state;
|
} state;
|
||||||
|
|
||||||
// where a field is written: the finest beside the scenery as <scenario>.ehf, coarser ones as
|
// beside the scenery, under its name: the same place the heightfield used to be cooked to
|
||||||
// <scenario>.<field>.ehf
|
std::string archive_path( std::string const &Sceneryfile ) { return scenery_sidecar( Sceneryfile, "_terrain.pak" ); }
|
||||||
std::string
|
std::string note_path( std::string const &Sceneryfile ) { return scenery_sidecar( Sceneryfile, "_terrain.nodes" ); }
|
||||||
heightfield_path( std::string const &Sceneryfile, std::uint32_t const Field = 0 ) {
|
std::string manifest_path( std::string const &Sceneryfile ) { return scenery_sidecar( Sceneryfile, "_terrain.deps" ); }
|
||||||
|
std::string scratch_path( std::string const &Sceneryfile ) { return scenery_sidecar( Sceneryfile, "_terrain.cook" ); }
|
||||||
|
|
||||||
return scenery_sidecar( Sceneryfile, Field == 0 ? std::string( ".ehf" ) : "." + std::to_string( Field ) + ".ehf" );
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string
|
|
||||||
table_path( std::string const &Heightfield ) {
|
|
||||||
|
|
||||||
return Heightfield + ".nodes";
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string
|
|
||||||
manifest_path( std::string const &Heightfield ) {
|
|
||||||
|
|
||||||
return Heightfield + ".deps";
|
|
||||||
}
|
|
||||||
|
|
||||||
// throws away every file of a bake, so the next start makes a new one
|
|
||||||
void
|
void
|
||||||
discard( std::string const &Sceneryfile ) {
|
discard( std::string const &Sceneryfile ) {
|
||||||
|
|
||||||
std::error_code error;
|
std::error_code error;
|
||||||
for( std::uint32_t field = 0; field < fieldcount; ++field ) {
|
for( auto const &path : {
|
||||||
std::filesystem::remove( heightfield_path( Sceneryfile, field ), error );
|
archive_path( Sceneryfile ), note_path( Sceneryfile ),
|
||||||
|
manifest_path( Sceneryfile ), scratch_path( Sceneryfile ) } ) {
|
||||||
|
std::filesystem::remove( path, error );
|
||||||
}
|
}
|
||||||
std::filesystem::remove( table_path( heightfield_path( Sceneryfile ) ), error );
|
|
||||||
std::filesystem::remove( manifest_path( heightfield_path( Sceneryfile ) ), error );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool
|
bool
|
||||||
load_table( std::string const &Path ) {
|
load_note( std::string const &Path ) {
|
||||||
|
|
||||||
std::ifstream input( Path, std::ios::binary );
|
std::ifstream file( Path, std::ios::binary );
|
||||||
if( false == input.good() ) { return false; }
|
if( false == file.good() ) { return false; }
|
||||||
|
note_header header {};
|
||||||
|
file.read( reinterpret_cast<char *>( &header ), sizeof( header ) );
|
||||||
|
if( false == file.good() ) { return false; }
|
||||||
|
if( 0 != std::memcmp( header.magic, notemagic, sizeof( notemagic ) ) ) { return false; }
|
||||||
|
if( header.version != noteversion ) { return false; }
|
||||||
|
if( header.rules != quantizedmesh::cook_rules ) { return false; }
|
||||||
|
|
||||||
table_header header {};
|
state.note.reserve( header.count );
|
||||||
input.read( reinterpret_cast<char *>( &header ), sizeof( header ) );
|
for( std::uint64_t index = 0; index < header.count; ++index ) {
|
||||||
if( ( false == static_cast<bool>( input ) )
|
note_record record {};
|
||||||
|| ( 0 != std::memcmp( header.magic, tablemagic, sizeof( tablemagic ) ) )
|
file.read( reinterpret_cast<char *>( &record ), sizeof( record ) );
|
||||||
|| ( header.version != tableversion )
|
if( false == file.good() ) { state.note.clear(); return false; }
|
||||||
|| ( header.selection != heightfield::selection_rules ) ) {
|
state.note.emplace( record.key, record );
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<table_record> records( header.count );
|
|
||||||
input.read( reinterpret_cast<char *>( records.data() ), static_cast<std::streamsize>( header.count * sizeof( table_record ) ) );
|
|
||||||
state.masks.resize( header.maskbytes );
|
state.masks.resize( header.maskbytes );
|
||||||
input.read( reinterpret_cast<char *>( state.masks.data() ), static_cast<std::streamsize>( header.maskbytes ) );
|
if( header.maskbytes > 0 ) {
|
||||||
if( false == static_cast<bool>( input ) ) { return false; }
|
file.read( reinterpret_cast<char *>( state.masks.data() ), static_cast<std::streamsize>( header.maskbytes ) );
|
||||||
|
|
||||||
state.table.reserve( records.size() );
|
|
||||||
for( auto const &record : records ) {
|
|
||||||
state.table.emplace( record.key, record );
|
|
||||||
}
|
}
|
||||||
|
if( false == file.good() ) { state.note.clear(); state.masks.clear(); return false; }
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
bool
|
||||||
write_table( std::string const &Path ) {
|
save_note( std::string const &Path, std::vector<std::uint32_t> const &Refusals ) {
|
||||||
|
|
||||||
// a key met more than once is the same line of the same file, included with the same
|
// the cook refuses a triangle that has no ground in it - a wall - and the scenery goes on
|
||||||
// parameters at the same place - the same surface: a triangle counts as shown if any of
|
// drawing that one. the note says so per node, as a bit each
|
||||||
// the copies is
|
std::vector<note_record> records;
|
||||||
std::unordered_map<std::uint64_t, std::vector<bool>> shown;
|
|
||||||
for( auto const &node : state.nodes ) {
|
|
||||||
auto &entry { shown[ node.key ] };
|
|
||||||
entry.resize( node.count, false );
|
|
||||||
for( std::uint32_t index = 0; index < node.count; ++index ) {
|
|
||||||
if( state.shown[ node.first + index ] != 0 ) { entry[ index ] = true; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<table_record> records;
|
|
||||||
std::vector<std::uint8_t> masks;
|
std::vector<std::uint8_t> masks;
|
||||||
|
records.reserve( state.nodes.size() );
|
||||||
|
|
||||||
|
auto refusal { Refusals.begin() };
|
||||||
for( auto const &node : state.nodes ) {
|
for( auto const &node : state.nodes ) {
|
||||||
auto const lookup { shown.find( node.key ) };
|
while( ( refusal != Refusals.end() ) && ( *refusal < node.first ) ) { ++refusal; }
|
||||||
if( lookup == shown.end() ) { continue; } // written already
|
auto const last { node.first + node.count };
|
||||||
auto const &entry { lookup->second };
|
auto const firstrefusal { refusal };
|
||||||
std::uint32_t count { 0 };
|
std::size_t refused { 0 };
|
||||||
for( auto const flag : entry ) { count += ( flag ? 1 : 0 ); }
|
for( auto walk = refusal; ( walk != Refusals.end() ) && ( *walk < last ); ++walk ) { ++refused; }
|
||||||
if( count > 0 ) {
|
if( refused == 0 ) {
|
||||||
table_record record { node.key, node.count, allshown };
|
records.push_back( { node.key, node.count, allshown } );
|
||||||
if( count < node.count ) {
|
continue;
|
||||||
record.mask = static_cast<std::uint32_t>( masks.size() );
|
|
||||||
auto const bytes { ( node.count + 7u ) / 8u };
|
|
||||||
masks.resize( masks.size() + bytes, 0u );
|
|
||||||
for( std::uint32_t index = 0; index < node.count; ++index ) {
|
|
||||||
if( false == entry[ index ] ) {
|
|
||||||
masks[ record.mask + index / 8u ] |= static_cast<std::uint8_t>( 1u << ( index % 8u ) );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
records.emplace_back( record );
|
|
||||||
}
|
}
|
||||||
shown.erase( lookup );
|
if( refused == node.count ) {
|
||||||
|
// nothing of this node is in the tiles: it is drawn as written, and a node the note
|
||||||
|
// says nothing about is exactly that
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
auto const at { static_cast<std::uint32_t>( masks.size() ) };
|
||||||
|
masks.resize( masks.size() + ( node.count + 7 ) / 8, 0 );
|
||||||
|
for( auto walk = firstrefusal; ( walk != Refusals.end() ) && ( *walk < last ); ++walk ) {
|
||||||
|
auto const bit { *walk - node.first };
|
||||||
|
masks[ at + bit / 8 ] |= static_cast<std::uint8_t>( 1u << ( bit % 8 ) );
|
||||||
|
}
|
||||||
|
records.push_back( { node.key, node.count, at } );
|
||||||
}
|
}
|
||||||
|
|
||||||
table_header header {};
|
std::ofstream file( Path, std::ios::binary | std::ios::trunc );
|
||||||
std::memcpy( header.magic, tablemagic, sizeof( tablemagic ) );
|
if( false == file.good() ) { return false; }
|
||||||
header.version = tableversion;
|
note_header header {};
|
||||||
header.selection = heightfield::selection_rules;
|
std::memcpy( header.magic, notemagic, sizeof( notemagic ) );
|
||||||
|
header.version = noteversion;
|
||||||
|
header.rules = quantizedmesh::cook_rules;
|
||||||
header.count = records.size();
|
header.count = records.size();
|
||||||
header.maskbytes = masks.size();
|
header.maskbytes = masks.size();
|
||||||
|
file.write( reinterpret_cast<char const *>( &header ), sizeof( header ) );
|
||||||
std::ofstream output( Path, std::ios::binary | std::ios::trunc );
|
for( auto const &record : records ) {
|
||||||
output.write( reinterpret_cast<char const *>( &header ), sizeof( header ) );
|
file.write( reinterpret_cast<char const *>( &record ), sizeof( record ) );
|
||||||
output.write( reinterpret_cast<char const *>( records.data() ), static_cast<std::streamsize>( records.size() * sizeof( table_record ) ) );
|
}
|
||||||
output.write( reinterpret_cast<char const *>( masks.data() ), static_cast<std::streamsize>( masks.size() ) );
|
if( false == masks.empty() ) {
|
||||||
|
file.write( reinterpret_cast<char const *>( masks.data() ), static_cast<std::streamsize>( masks.size() ) );
|
||||||
std::size_t full { 0 };
|
}
|
||||||
for( auto const &record : records ) { full += ( record.mask == allshown ? 1 : 0 ); }
|
return file.good();
|
||||||
WriteLog(
|
|
||||||
"Terrain bake: " + std::to_string( records.size() ) + " of " + std::to_string( state.nodes.size() )
|
|
||||||
+ " triangle nodes are shown by the heightfield, " + std::to_string( full ) + " of them entirely" );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// the field a triangle belongs to, given the finest step
|
// the key a node is known by. what it is made of, where it stands, and where it is written: the
|
||||||
std::uint32_t
|
// manifest vouches for the content of the file, so its name is enough here
|
||||||
field_of( terrain::vertex const &A, terrain::vertex const &B, terrain::vertex const &C, double const Basestep ) {
|
|
||||||
|
|
||||||
auto const longest { std::max( { terrain::planar_length( A, B ), terrain::planar_length( B, C ), terrain::planar_length( C, A ) } ) };
|
|
||||||
std::uint32_t field { 0 };
|
|
||||||
auto step { Basestep };
|
|
||||||
while( ( field + 1 < fieldcount ) && ( longest / ( step * fieldstride ) >= samplesperedge ) ) {
|
|
||||||
step *= fieldstride;
|
|
||||||
++field;
|
|
||||||
}
|
|
||||||
return field;
|
|
||||||
}
|
|
||||||
|
|
||||||
using fields = std::array<std::unique_ptr<terrain::cooker>, fieldcount>;
|
|
||||||
|
|
||||||
// one pass over the spill file: lays every triangle into fresh cookers for its own field and
|
|
||||||
// the coarser ones, wherever Keep has the bit of that field set
|
|
||||||
fields
|
|
||||||
lay( std::string const &Spillpath, double const Basestep, std::vector<std::uint8_t> const &Keep,
|
|
||||||
std::vector<std::uint8_t> *Ownfield = nullptr ) {
|
|
||||||
|
|
||||||
fields result;
|
|
||||||
auto step { Basestep };
|
|
||||||
for( auto &cooker : result ) {
|
|
||||||
cooker = std::make_unique<terrain::cooker>();
|
|
||||||
if( false == loadprofile::enabled() ) {
|
|
||||||
// the per-material survival table is what a bad bake is diagnosed from, so it is
|
|
||||||
// printed when the load is being measured anyway
|
|
||||||
cooker->quiet();
|
|
||||||
}
|
|
||||||
cooker->configure( step, true );
|
|
||||||
step *= fieldstride;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::ifstream input( Spillpath, std::ios::binary );
|
|
||||||
std::vector<spilled> chunk( 4096 );
|
|
||||||
while( input ) {
|
|
||||||
input.read( reinterpret_cast<char *>( chunk.data() ), static_cast<std::streamsize>( chunk.size() * sizeof( spilled ) ) );
|
|
||||||
auto const count { static_cast<std::size_t>( input.gcount() ) / sizeof( spilled ) };
|
|
||||||
for( std::size_t index = 0; index < count; ++index ) {
|
|
||||||
auto const &triangle { chunk[ index ] };
|
|
||||||
if( Keep[ triangle.id ] == 0 ) { continue; }
|
|
||||||
auto const corner = [ &triangle ]( std::size_t const Corner ) {
|
|
||||||
auto const *values { triangle.corners + Corner * 5 };
|
|
||||||
return terrain::vertex { values[ 0 ], values[ 1 ], values[ 2 ], values[ 3 ], values[ 4 ] }; };
|
|
||||||
auto const a { corner( 0 ) }, b { corner( 1 ) }, c { corner( 2 ) };
|
|
||||||
auto const own { field_of( a, b, c, Basestep ) };
|
|
||||||
if( Ownfield != nullptr ) { ( *Ownfield )[ triangle.id ] = static_cast<std::uint8_t>( own ); }
|
|
||||||
for( auto field { own }; field < fieldcount; ++field ) {
|
|
||||||
if( ( Keep[ triangle.id ] & ( 1u << field ) ) == 0 ) { continue; }
|
|
||||||
result[ field ]->rasterize( a, b, c, state.nodes[ triangle.node ].material, triangle.id );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // anonymous namespace
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
std::uint64_t
|
std::uint64_t
|
||||||
node_key(
|
node_key(
|
||||||
std::string const &File, std::size_t const Line, std::vector<std::string> const &Parameters,
|
std::string const &File, std::size_t const Line, std::vector<std::string> const &Parameters,
|
||||||
std::string_view const Type, glm::dvec3 const &Offset, glm::vec3 const &Rotation ) {
|
std::string_view const Type, glm::dvec3 const &Offset, glm::vec3 const &Rotation ) {
|
||||||
|
|
||||||
// fnv-1a. stable across runs and builds, which a std::hash is not required to be
|
|
||||||
std::uint64_t hash { 0xcbf29ce484222325ull };
|
std::uint64_t hash { 0xcbf29ce484222325ull };
|
||||||
auto const mix {
|
auto const mix {
|
||||||
[ &hash ]( void const *Data, std::size_t const Size ) {
|
[ &hash ]( void const *Data, std::size_t const Size ) {
|
||||||
auto const *bytes { static_cast<unsigned char const *>( Data ) };
|
auto const *bytes { static_cast<std::uint8_t const *>( Data ) };
|
||||||
for( std::size_t index = 0; index < Size; ++index ) {
|
for( std::size_t index = 0; index < Size; ++index ) {
|
||||||
hash ^= bytes[ index ];
|
hash ^= bytes[ index ];
|
||||||
hash *= 0x100000001b3ull;
|
hash *= 0x100000001b3ull;
|
||||||
@@ -313,7 +200,6 @@ node_key(
|
|||||||
mix( Text.data(), Text.size() );
|
mix( Text.data(), Text.size() );
|
||||||
mix( "\n", 1 ); } };
|
mix( "\n", 1 ); } };
|
||||||
|
|
||||||
// the file's content is vouched for by the bake's manifest, so its name is enough here
|
|
||||||
text( File );
|
text( File );
|
||||||
auto const line { static_cast<std::uint64_t>( Line ) };
|
auto const line { static_cast<std::uint64_t>( Line ) };
|
||||||
mix( &line, sizeof( line ) );
|
mix( &line, sizeof( line ) );
|
||||||
@@ -324,81 +210,16 @@ node_key(
|
|||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // anonymous namespace
|
bool planning() { return false == state.note.empty(); }
|
||||||
|
// collecting, and not overruled by a terrain the scenery named part way in
|
||||||
void
|
bool baking() { return state.collecting && Global.terrain_heightfields.empty(); }
|
||||||
begin( std::string const &Sceneryfile ) {
|
|
||||||
|
|
||||||
state = {};
|
|
||||||
|
|
||||||
if( false == Global.terrain_heightfields.empty() ) {
|
|
||||||
// the scenery names its own, which always wins over anything baked beside it
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if( false == Global.bake_terrain ) {
|
|
||||||
// neither bake nor pick one up: the triangles as written
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto const path { heightfield_path( Sceneryfile ) };
|
|
||||||
std::error_code error;
|
|
||||||
if( true == FileExists( table_path( path ) ) ) {
|
|
||||||
// a bake is a cache: one this build cannot read, one cooked under older rules about
|
|
||||||
// what counts as terrain, or one missing a part, is thrown away and baked again
|
|
||||||
// rather than used
|
|
||||||
std::vector<std::string> baked;
|
|
||||||
auto usable {
|
|
||||||
( true == cookdeps::current( manifest_path( path ) ) )
|
|
||||||
&& ( true == load_table( table_path( path ) ) ) };
|
|
||||||
for( std::uint32_t field = 0; ( field < fieldcount ) && ( true == usable ); ++field ) {
|
|
||||||
auto const fieldpath { heightfield_path( Sceneryfile, field ) };
|
|
||||||
if( false == FileExists( fieldpath ) ) { continue; }
|
|
||||||
heightfield::reader probe;
|
|
||||||
usable = ( true == probe.open( fieldpath ) ) && ( probe.selection() == heightfield::selection_rules );
|
|
||||||
baked.push_back( fieldpath );
|
|
||||||
}
|
|
||||||
if( ( true == usable ) && ( false == baked.empty() ) ) {
|
|
||||||
for( auto const &fieldpath : baked ) {
|
|
||||||
Global.terrain_heightfields.push_back( fieldpath );
|
|
||||||
}
|
|
||||||
WriteLog(
|
|
||||||
"Terrain bake: using " + std::to_string( baked.size() ) + " heightfields baked earlier, "
|
|
||||||
+ std::to_string( state.table.size() ) + " triangle nodes replaced by them" );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
WriteLog( "Terrain bake: the heightfields beside this scenery are out of date, baking them again" );
|
|
||||||
state.table.clear();
|
|
||||||
state.masks.clear();
|
|
||||||
discard( Sceneryfile );
|
|
||||||
}
|
|
||||||
|
|
||||||
// the triangles wait on disk beside the heightfield rather than in memory: a large scenery
|
|
||||||
// holds millions of them, and the bake reads them several times
|
|
||||||
state.spillpath = path + ".bake";
|
|
||||||
state.spill.open( state.spillpath, std::ios::binary | std::ios::trunc );
|
|
||||||
if( false == state.spill.good() ) {
|
|
||||||
ErrorLog( "Terrain bake: cannot write \"" + state.spillpath + "\", not baking" );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
WriteLog( "Terrain bake: no heightfield for this scenery, baking one. This load will be slow; the next will not" );
|
|
||||||
state.collecting = true;
|
|
||||||
state.started = std::chrono::steady_clock::now();
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
bool
|
|
||||||
planning() {
|
|
||||||
|
|
||||||
return false == state.table.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
node_plan
|
node_plan
|
||||||
plan( std::uint64_t const Key ) {
|
plan( std::uint64_t const Key ) {
|
||||||
|
|
||||||
node_plan result;
|
node_plan result;
|
||||||
auto const lookup { state.table.find( Key ) };
|
auto const lookup { state.note.find( Key ) };
|
||||||
if( lookup == state.table.end() ) {
|
if( lookup == state.note.end() ) {
|
||||||
++state.drawn;
|
++state.drawn;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -413,19 +234,74 @@ plan( std::uint64_t const Key ) {
|
|||||||
result.drawn.resize( record.triangles, false );
|
result.drawn.resize( record.triangles, false );
|
||||||
for( std::uint32_t index = 0; index < record.triangles; ++index ) {
|
for( std::uint32_t index = 0; index < record.triangles; ++index ) {
|
||||||
auto const byte { record.mask + index / 8u };
|
auto const byte { record.mask + index / 8u };
|
||||||
result.drawn[ index ] = ( byte < state.masks.size() ) && ( ( state.masks[ byte ] >> ( index % 8u ) ) & 1u ) != 0;
|
result.drawn[ index ] =
|
||||||
|
( byte < state.masks.size() ) && ( ( ( state.masks[ byte ] >> ( index % 8u ) ) & 1u ) != 0 );
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// collecting for a bake, and not overruled by a heightfield the scenery named part way in
|
} // anonymous namespace
|
||||||
bool
|
|
||||||
baking() {
|
|
||||||
|
|
||||||
return state.collecting && Global.terrain_heightfields.empty();
|
void
|
||||||
|
begin( std::string const &Sceneryfile ) {
|
||||||
|
|
||||||
|
state.collecting = false;
|
||||||
|
state.nodes.clear();
|
||||||
|
state.triangles = 0;
|
||||||
|
state.objects = 0;
|
||||||
|
state.objecttriangles = 0;
|
||||||
|
state.note.clear();
|
||||||
|
state.masks.clear();
|
||||||
|
state.skipped = state.filtered = state.drawn = 0;
|
||||||
|
|
||||||
|
if( false == Global.terrain_heightfields.empty() ) {
|
||||||
|
// the scenery names a terrain of its own, which wins over anything cooked beside it
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if( false == Global.bake_terrain ) {
|
||||||
|
// neither cook nor pick one up: the triangles as written
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto const archive { archive_path( Sceneryfile ) };
|
||||||
|
if( true == FileExists( note_path( Sceneryfile ) ) ) {
|
||||||
|
// a cook is a cache. one this build cannot read, one made under other rules about what
|
||||||
|
// counts as ground, or one made from a scenery that has changed since, is thrown away
|
||||||
|
// and cooked again rather than used
|
||||||
|
quantizedmesh::reader probe;
|
||||||
|
auto const usable {
|
||||||
|
( true == cookdeps::current( manifest_path( Sceneryfile ) ) )
|
||||||
|
&& ( true == probe.open( archive ) )
|
||||||
|
&& ( true == load_note( note_path( Sceneryfile ) ) ) };
|
||||||
|
if( true == usable ) {
|
||||||
|
Global.terrain_heightfields.push_back( archive );
|
||||||
|
WriteLog(
|
||||||
|
"Terrain cook: using tiles cooked earlier, " + std::to_string( probe.table().levels )
|
||||||
|
+ " levels over " + std::to_string( static_cast<int>( probe.table().side ) ) + " m, "
|
||||||
|
+ std::to_string( state.note.size() ) + " triangle nodes stood for" );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WriteLog( "Terrain cook: the tiles beside this scenery are out of date, cooking them again" );
|
||||||
|
state.note.clear();
|
||||||
|
state.masks.clear();
|
||||||
|
discard( Sceneryfile );
|
||||||
|
}
|
||||||
|
|
||||||
|
if( false == state.cooker.begin( scratch_path( Sceneryfile ), tilebudget ) ) {
|
||||||
|
ErrorLog( "Terrain cook: cannot write \"" + scratch_path( Sceneryfile ) + "\", not cooking" );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.cooker.quiet();
|
||||||
|
WriteLog( "Terrain cook: no tiles for this scenery, cooking them. This load will be slow; the next will not" );
|
||||||
|
state.collecting = true;
|
||||||
|
state.started = std::chrono::steady_clock::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
} // anonymous namespace
|
bool
|
||||||
|
collecting() {
|
||||||
|
|
||||||
|
return state.collecting;
|
||||||
|
}
|
||||||
|
|
||||||
node_decision
|
node_decision
|
||||||
examine(
|
examine(
|
||||||
@@ -435,47 +311,61 @@ examine(
|
|||||||
node_decision decision;
|
node_decision decision;
|
||||||
if( ( true == baking() ) || ( true == planning() ) ) {
|
if( ( true == baking() ) || ( true == planning() ) ) {
|
||||||
decision.key = node_key( File, Line, Parameters, Type, Offset, Rotation );
|
decision.key = node_key( File, Line, Parameters, Type, Offset, Rotation );
|
||||||
decision.bake = baking();
|
// only what the scenery drew in world space: a node under origin, rotate or scale is an
|
||||||
|
// object standing on the ground rather than the ground itself
|
||||||
|
decision.bake = baking() && Worldspace;
|
||||||
if( false == decision.bake ) {
|
if( false == decision.bake ) {
|
||||||
decision.plan = plan( decision.key );
|
decision.plan = plan( decision.key );
|
||||||
decision.skip = ( decision.plan.what == verdict::skip );
|
decision.skip = ( decision.plan.what == verdict::skip );
|
||||||
}
|
}
|
||||||
return decision;
|
return decision;
|
||||||
}
|
}
|
||||||
// a heightfield the scenery names itself, cooked by hand, replaces the world space
|
// a terrain the scenery names itself replaces the world space triangles outright, as it
|
||||||
// triangles outright, as it always has
|
// always has
|
||||||
decision.skip = ( true == Worldspace ) && ( false == Global.terrain_heightfields.empty() );
|
decision.skip = ( true == Worldspace ) && ( false == Global.terrain_heightfields.empty() );
|
||||||
return decision;
|
return decision;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What is ground and what merely stands on it.
|
||||||
|
//
|
||||||
|
// The cook lays a triangle whichever way it faces: the side of an embankment or a cutting stands
|
||||||
|
// upright and is ground all the same, and leaving those out is what opens a hole along every bank.
|
||||||
|
// So orientation cannot be the test. Two things the scenery says about a node serve instead.
|
||||||
|
//
|
||||||
|
// A node under origin, rotate or scale is an object the scenery placed, not ground it drew, and
|
||||||
|
// examine() keeps those out. And a material with an alpha channel is a tree, a bush, a fence or a
|
||||||
|
// catenary mast: the ground is opaque. That matters twice over - the terrain is drawn without an
|
||||||
|
// alpha test, so a billboard would come out as a full sheet, and every level above the finest is one
|
||||||
|
// simplified surface, which cannot be fitted through the ground and through what stands on it at
|
||||||
|
// once.
|
||||||
void
|
void
|
||||||
add( std::uint64_t const Key, std::vector<world_vertex> const &Vertices, std::string_view const Material ) {
|
add(
|
||||||
|
std::uint64_t const Key, std::vector<world_vertex> const &Vertices, std::string_view const Material,
|
||||||
|
bool const Translucent ) {
|
||||||
|
|
||||||
if( false == state.collecting ) { return; }
|
if( false == state.collecting ) { return; }
|
||||||
|
|
||||||
auto const count { static_cast<std::uint32_t>( Vertices.size() / 3 ) };
|
auto const count { static_cast<std::uint32_t>( Vertices.size() / 3 ) };
|
||||||
auto const node { static_cast<std::uint32_t>( state.nodes.size() ) };
|
if( count == 0 ) { return; }
|
||||||
state.nodes.push_back( { Key, state.triangles, count, std::string { Material } } );
|
if( true == Translucent ) {
|
||||||
|
// no record of it, and a node the note says nothing about is one the scenery draws as written
|
||||||
|
++state.objects;
|
||||||
|
state.objecttriangles += count;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.nodes.push_back( { Key, state.triangles, count } );
|
||||||
|
|
||||||
spilled triangle {};
|
auto const corner {
|
||||||
triangle.node = node;
|
[]( world_vertex const &Vertex ) {
|
||||||
|
return quantizedmesh::cooker::corner {
|
||||||
|
Vertex.position.x, Vertex.position.y, Vertex.position.z,
|
||||||
|
Vertex.texture.x, Vertex.texture.y }; } };
|
||||||
for( std::uint32_t index = 0; index < count; ++index ) {
|
for( std::uint32_t index = 0; index < count; ++index ) {
|
||||||
for( std::size_t corner = 0; corner < 3; ++corner ) {
|
state.cooker.add(
|
||||||
auto const &source { Vertices[ index * 3u + corner ] };
|
corner( Vertices[ index * 3u + 0 ] ),
|
||||||
auto *values { triangle.corners + corner * 5 };
|
corner( Vertices[ index * 3u + 1 ] ),
|
||||||
values[ 0 ] = source.position.x;
|
corner( Vertices[ index * 3u + 2 ] ),
|
||||||
values[ 1 ] = source.position.y;
|
Material );
|
||||||
values[ 2 ] = source.position.z;
|
|
||||||
values[ 3 ] = source.texture.x;
|
|
||||||
values[ 4 ] = source.texture.y;
|
|
||||||
// the grid step is a property of the whole terrain; the first vertices that turn up
|
|
||||||
// are enough to tell it
|
|
||||||
if( state.sample.size() < samplegoal ) {
|
|
||||||
state.sample.push_back( { values[ 0 ], values[ 1 ], values[ 2 ], values[ 3 ], values[ 4 ] } );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
triangle.id = state.triangles + index;
|
|
||||||
state.spill.write( reinterpret_cast<char const *>( &triangle ), sizeof( triangle ) );
|
|
||||||
}
|
}
|
||||||
state.triangles += count;
|
state.triangles += count;
|
||||||
}
|
}
|
||||||
@@ -485,117 +375,86 @@ finish( std::string const &Sceneryfile, std::vector<std::string> const &Included
|
|||||||
|
|
||||||
if( true == planning() ) {
|
if( true == planning() ) {
|
||||||
WriteLog(
|
WriteLog(
|
||||||
"Terrain bake: " + std::to_string( state.skipped ) + " triangle nodes left to the heightfield, "
|
"Terrain cook: " + std::to_string( state.skipped ) + " triangle nodes left to the tiles, "
|
||||||
+ std::to_string( state.filtered ) + " drawn in part, " + std::to_string( state.drawn ) + " drawn as written" );
|
+ std::to_string( state.filtered ) + " drawn in part, " + std::to_string( state.drawn )
|
||||||
// an include added or taken away since the bake: nodes of a new file were drawn as
|
+ " drawn as written" );
|
||||||
// geometry this time, as none of their keys is known, but the next start bakes again
|
// an include added or taken away since the cook: the nodes of a new file were drawn as
|
||||||
if( false == cookdeps::lists( manifest_path( heightfield_path( Sceneryfile ) ), Included ) ) {
|
// geometry this time, since none of their keys is known, but the next start cooks again
|
||||||
WriteLog( "Terrain bake: the scenery includes different files than when it was baked; baking again on the next start" );
|
if( false == cookdeps::lists( manifest_path( Sceneryfile ), Included ) ) {
|
||||||
|
WriteLog( "Terrain cook: the scenery includes different files than when it was cooked; cooking again on the next start" );
|
||||||
discard( Sceneryfile );
|
discard( Sceneryfile );
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if( false == state.collecting ) { return; }
|
if( false == state.collecting ) { return; }
|
||||||
|
state.collecting = false;
|
||||||
|
|
||||||
if( false == Global.terrain_heightfields.empty() ) {
|
if( false == Global.terrain_heightfields.empty() ) {
|
||||||
// the scenery named its own heightfield part way through, and that one wins
|
WriteLog( "Terrain cook: the scenery names a terrain of its own, nothing cooked" );
|
||||||
WriteLog( "Terrain bake: the scenery names its own heightfield, nothing baked" );
|
|
||||||
state.spill.close();
|
|
||||||
std::error_code error;
|
|
||||||
std::filesystem::remove( state.spillpath, error );
|
|
||||||
state = {};
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.spill.close();
|
|
||||||
auto const cleanup = []() {
|
|
||||||
std::error_code error;
|
|
||||||
std::filesystem::remove( state.spillpath, error );
|
|
||||||
state = {};
|
|
||||||
};
|
|
||||||
if( state.triangles == 0 ) {
|
if( state.triangles == 0 ) {
|
||||||
WriteLog( "Terrain bake: the scenery holds no triangles, nothing baked" );
|
WriteLog( "Terrain cook: no ground in this scenery, nothing cooked" );
|
||||||
cleanup();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto const step { terrain::detect_step( state.sample ) };
|
// the cook itself, apart from the load that fed it: what a second start saves is the cook
|
||||||
WriteLog( "Terrain bake: grid step " + std::to_string( step ) + " m, from " + std::to_string( state.sample.size() ) + " sampled vertices" );
|
auto const cookstarted { std::chrono::steady_clock::now() };
|
||||||
|
auto const archive { archive_path( Sceneryfile ) };
|
||||||
|
pak::writer writer;
|
||||||
|
if( false == writer.open( archive ) ) {
|
||||||
|
ErrorLog( "Terrain cook: cannot write \"" + archive + "\"" );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// The heightfield should hold the triangles it replaces and nothing else: anything it
|
auto written { true };
|
||||||
// holds that is also drawn as geometry is drawn twice, and a roof or a bridge deck left in
|
quantizedmesh::terrain_table table;
|
||||||
// it bends the ground. But a replaced triangle is only drawn whole if every cell around it
|
auto const sink {
|
||||||
// is, and those cells have corners owned by neighbours that are not replaced - the edge of
|
[ &writer, &written ]( quantizedmesh::tile_address const &Tile, std::vector<std::uint8_t> const &Bytes ) {
|
||||||
// the ground, a slope too steep, a patch too small. So the first pass lays everything and
|
written = writer.add( quantizedmesh::tile_name( Tile ), Bytes ) && written; } };
|
||||||
// decides what is replaced; the second lays the replaced triangles together with the
|
|
||||||
// neighbours that support them, and that is what gets written. The replaced triangles
|
if( ( false == state.cooker.finish( sink, table ) ) || ( false == written ) ) {
|
||||||
// stay replaced in it: their supports are all there, and they have fewer rivals than
|
ErrorLog( "Terrain cook: cooking failed, no tiles written" );
|
||||||
// before. The supports are drawn both ways, and the depth offset lets the geometry win.
|
writer.close();
|
||||||
std::vector<std::uint8_t> ownfield( state.triangles, 0u );
|
discard( Sceneryfile );
|
||||||
auto first { lay( state.spillpath, step, std::vector<std::uint8_t>( state.triangles, 0xffu ), &ownfield ) };
|
return;
|
||||||
// per triangle, the fields it goes into on the second pass: those that show it, and those
|
|
||||||
// where it supports a triangle they show
|
|
||||||
std::vector<std::uint8_t> keep( state.triangles, 0u );
|
|
||||||
state.shown.assign( state.triangles, 0u );
|
|
||||||
for( std::uint32_t field = 0; field < fieldcount; ++field ) {
|
|
||||||
first[ field ]->settle();
|
|
||||||
for( std::uint32_t id = 0; id < state.triangles; ++id ) {
|
|
||||||
if( true == first[ field ]->represented( id ) ) {
|
|
||||||
state.shown[ id ] = 1u;
|
|
||||||
keep[ id ] |= static_cast<std::uint8_t>( 1u << field );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// a triangle no field shows, counted by the reason its own field gives
|
if( false == writer.add( quantizedmesh::tablename, quantizedmesh::write_table( table ) ) ) {
|
||||||
terrain::cooker::refusals refused;
|
written = false;
|
||||||
for( std::uint32_t id = 0; id < state.triangles; ++id ) {
|
|
||||||
if( state.shown[ id ] == 0 ) { first[ ownfield[ id ] ]->refusal_of( id, refused ); }
|
|
||||||
}
|
}
|
||||||
|
if( ( false == writer.close() ) || ( false == written ) ) {
|
||||||
|
ErrorLog( "Terrain cook: writing \"" + archive + "\" failed" );
|
||||||
|
discard( Sceneryfile );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto const &report { state.cooker.result() };
|
||||||
|
if( false == save_note( note_path( Sceneryfile ), state.cooker.refusals() ) ) {
|
||||||
|
ErrorLog( "Terrain cook: cannot write \"" + note_path( Sceneryfile ) + "\"" );
|
||||||
|
discard( Sceneryfile );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if( false == cookdeps::write( manifest_path( Sceneryfile ), Included ) ) {
|
||||||
|
discard( Sceneryfile );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto const now { std::chrono::steady_clock::now() };
|
||||||
|
auto const cooking { std::chrono::duration<double>( now - cookstarted ).count() };
|
||||||
|
auto const collecting { std::chrono::duration<double>( now - state.started ).count() };
|
||||||
WriteLog(
|
WriteLog(
|
||||||
"Terrain bake: not replaced - " + std::to_string( refused.upright ) + " standing on edge, "
|
"Terrain cook: " + std::to_string( state.triangles ) + " triangles of ground in "
|
||||||
+ std::to_string( refused.unreached ) + " too small, " + std::to_string( refused.outvoted ) + " under other surfaces, "
|
+ std::to_string( state.nodes.size() ) + " nodes, " + std::to_string( state.objecttriangles )
|
||||||
+ std::to_string( refused.bordering ) + " next to a hole" );
|
+ " triangles in " + std::to_string( state.objects ) + " nodes left standing on it, into "
|
||||||
std::vector<std::uint8_t> support( state.triangles, 0u );
|
+ std::to_string( report.tiles ) + " tiles of " + std::to_string( static_cast<int>( state.cooker.finesttile() ) )
|
||||||
for( std::uint32_t field = 0; field < fieldcount; ++field ) {
|
+ " m over " + std::to_string( report.levels ) + " levels, "
|
||||||
std::fill( support.begin(), support.end(), 0u );
|
+ std::to_string( report.triangles ) + " triangles and " + std::to_string( report.vertices )
|
||||||
first[ field ]->mark_support( support );
|
+ " vertices written, " + std::to_string( report.refused ) + " triangles left to the scenery, "
|
||||||
first[ field ].reset();
|
+ std::to_string( writer.bytes() / 1024 ) + " kB, cooked in "
|
||||||
for( std::uint32_t id = 0; id < state.triangles; ++id ) {
|
+ std::to_string( static_cast<int>( cooking ) ) + " s of a "
|
||||||
if( support[ id ] != 0 ) { keep[ id ] |= static_cast<std::uint8_t>( 1u << field ); }
|
+ std::to_string( static_cast<int>( collecting ) ) + " s load" );
|
||||||
}
|
|
||||||
}
|
|
||||||
auto const shown { static_cast<std::size_t>( std::count( state.shown.begin(), state.shown.end(), 1u ) ) };
|
|
||||||
std::size_t supports { 0 };
|
|
||||||
for( std::uint32_t id = 0; id < state.triangles; ++id ) {
|
|
||||||
if( ( keep[ id ] != 0 ) && ( state.shown[ id ] == 0 ) ) { ++supports; }
|
|
||||||
}
|
|
||||||
|
|
||||||
auto const second { lay( state.spillpath, step, keep ) };
|
Global.terrain_heightfields.push_back( archive );
|
||||||
std::string written;
|
|
||||||
std::error_code error;
|
|
||||||
std::uintmax_t bytes { 0 };
|
|
||||||
for( std::uint32_t field = 0; field < fieldcount; ++field ) {
|
|
||||||
auto const path { heightfield_path( Sceneryfile, field ) };
|
|
||||||
std::filesystem::remove( path, error );
|
|
||||||
if( true == second[ field ]->empty() ) { continue; }
|
|
||||||
second[ field ]->finish( path, {} );
|
|
||||||
auto const size { std::filesystem::file_size( path, error ) };
|
|
||||||
bytes += ( error ? 0 : size );
|
|
||||||
written +=
|
|
||||||
( written.empty() ? "" : ", " ) + ( "\"" + path + "\" at " )
|
|
||||||
+ std::to_string( step * std::pow( fieldstride, field ) ) + " m";
|
|
||||||
}
|
|
||||||
write_table( table_path( heightfield_path( Sceneryfile ) ) );
|
|
||||||
cookdeps::write( manifest_path( heightfield_path( Sceneryfile ) ), Included );
|
|
||||||
|
|
||||||
auto const seconds {
|
|
||||||
std::chrono::duration<double>( std::chrono::steady_clock::now() - state.started ).count() };
|
|
||||||
WriteLog(
|
|
||||||
"Terrain bake: wrote " + written + "; " + std::to_string( shown ) + " of " + std::to_string( state.triangles )
|
|
||||||
+ " triangles replaced and " + std::to_string( supports ) + " more supporting them, "
|
|
||||||
+ std::to_string( seconds ) + " s, " + std::to_string( bytes / 1048576 ) + " MB" );
|
|
||||||
|
|
||||||
// the triangles of this load were all drawn; the table applies from the next one
|
|
||||||
cleanup();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace simulation::terrainbake
|
} // namespace simulation::terrainbake
|
||||||
|
|||||||
@@ -20,66 +20,54 @@ import eu07.model.vertex;
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
||||||
// Baking a scenery's heightfield on its first run, and deciding on every later run which
|
// Cooking a scenery's terrain into mesh tiles on its first run, and leaving out the triangles
|
||||||
// triangles it replaces.
|
// the tiles stand for on every run after that.
|
||||||
//
|
//
|
||||||
// A cooked heightfield is what makes a large scenery affordable - l204 loads in 25 seconds
|
// The first load lays every triangle of ground into the cook and writes the tiles beside the
|
||||||
// against 219, in 1.9 GB against 9.1 - but until now it had to be produced by hand with the
|
// scenery, along with a note of which nodes went into them and of what the scenery looked like
|
||||||
// terraincook tool and named in the scenery text. This bakes it the first time the scenery is
|
// at the time. Later loads read that note: a node the tiles stand for is not built at all, one
|
||||||
// opened and picks it up by itself afterwards.
|
// the tiles stand for in part is built without those triangles, and if anything the scenery is
|
||||||
//
|
// made of has changed since, the lot is cooked again.
|
||||||
// Nothing here guesses what terrain is. Every triangle the scenery draws is laid into the
|
|
||||||
// heightfield where it is drawn, and afterwards each one is asked whether the heightfield still
|
|
||||||
// shows it. A tree, a wall or a gantry stands on its edge and never reaches a sample; a bridge
|
|
||||||
// deck loses its samples to the ground beneath; those are drawn as they always were. Only
|
|
||||||
// triangles the heightfield really shows stop being drawn on their own, so nothing can vanish
|
|
||||||
// from the scenery - the worst a bad bake can do is draw something twice.
|
|
||||||
//
|
|
||||||
// A bake is used only while every file the scenery included when it was made still has the
|
|
||||||
// content it had (cookdeps): the scenario file itself is not among them, since the launcher
|
|
||||||
// rewrites it on every start and the terrain lives in what it includes. Within a bake that
|
|
||||||
// holds, a triangle node is recognised by the file and line it is written at, the parameters
|
|
||||||
// its include was given, and the origin and rotation in force.
|
|
||||||
//
|
|
||||||
// The rasterising is scene/terraincooker.h, the same code the tool uses.
|
|
||||||
namespace simulation::terrainbake {
|
namespace simulation::terrainbake {
|
||||||
|
|
||||||
// what to do with one triangle node
|
|
||||||
enum class verdict {
|
enum class verdict {
|
||||||
draw, // the heightfield does not show it: import and draw it as written
|
draw, // the tiles do not stand for this node: build it as written
|
||||||
skip, // the heightfield shows all of it: do not even import it
|
skip, // the tiles stand for all of it
|
||||||
filter // the heightfield shows some of it: import it and draw only the rest
|
filter // the tiles stand for part of it; build the rest
|
||||||
};
|
};
|
||||||
|
|
||||||
struct node_plan {
|
struct node_plan {
|
||||||
verdict what { verdict::draw };
|
verdict what { verdict::draw };
|
||||||
// for filter: one entry per triangle in import order, true where it is still drawn
|
// per triangle of the node, whether the scenery still draws it. only for verdict::filter
|
||||||
std::vector<bool> drawn;
|
std::vector<bool> drawn;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// called before the scenery is parsed. picks up a heightfield baked earlier together with its
|
|
||||||
// node table, or arms the bake when there is none
|
|
||||||
void begin( std::string const &Sceneryfile );
|
|
||||||
// what the load is to do with one triangle node, decided in one place: whether it is read at
|
|
||||||
// all, whether it goes into a bake, and which of its triangles are still drawn
|
|
||||||
struct node_decision {
|
struct node_decision {
|
||||||
bool skip { false }; // step over it without importing
|
bool skip { false };
|
||||||
bool bake { false }; // hand it to add() once imported
|
bool bake { false };
|
||||||
std::uint64_t key { 0 };
|
std::uint64_t key { 0 };
|
||||||
node_plan plan;
|
node_plan plan;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Worldspace says whether the node stands in world coordinates, which is all that decides it
|
// picks up tiles cooked earlier, or arms the cook when there are none
|
||||||
// when the scenery names a hand-cooked heightfield of its own
|
void begin( std::string const &Sceneryfile );
|
||||||
|
// whether triangles are being collected for a cook
|
||||||
|
bool collecting();
|
||||||
|
|
||||||
|
// what to do with one triangle node of the scenery
|
||||||
node_decision examine(
|
node_decision examine(
|
||||||
std::string const &File, std::size_t const Line, std::vector<std::string> const &Parameters,
|
std::string const &File, std::size_t const Line, std::vector<std::string> const &Parameters,
|
||||||
std::string_view const Type, glm::dvec3 const &Offset, glm::vec3 const &Rotation, bool const Worldspace );
|
std::string_view const Type, glm::dvec3 const &Offset, glm::vec3 const &Rotation, bool const Worldspace );
|
||||||
// one triangle node, in world space, as it is about to be drawn
|
|
||||||
void add( std::uint64_t const Key, std::vector<world_vertex> const &Vertices, std::string_view const Material );
|
// hands a node's triangles to the cook, where they are placed in the world. a material with an
|
||||||
// called once the scenery has been parsed, with every file it included. writes the
|
// alpha channel is not ground - it is a tree, a bush, a fence or a catenary mast - and is left to
|
||||||
// heightfields, their node table and the manifest of what they were made from; or, when a
|
// the scenery to draw
|
||||||
// bake was used, checks the scenery still includes what that bake was made from
|
void add(
|
||||||
|
std::uint64_t const Key, std::vector<world_vertex> const &Vertices, std::string_view const Material,
|
||||||
|
bool const Translucent );
|
||||||
|
|
||||||
|
// writes the tiles, or reports on what the tiles cooked earlier stood for. Included is every
|
||||||
|
// file the scenery read, which is what tells a later load whether it is still the same scenery
|
||||||
void finish( std::string const &Sceneryfile, std::vector<std::string> const &Included );
|
void finish( std::string const &Sceneryfile, std::vector<std::string> const &Included );
|
||||||
|
|
||||||
} // namespace simulation::terrainbake
|
} // namespace simulation::terrainbake
|
||||||
|
|||||||
@@ -13,19 +13,28 @@ if(NOT zstd_FOUND)
|
|||||||
add_library(zstd::libzstd ALIAS PkgConfig::ZSTD)
|
add_library(zstd::libzstd ALIAS PkgConfig::ZSTD)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
find_package(CDT CONFIG REQUIRED)
|
add_executable(terraincook
|
||||||
|
|
||||||
add_executable(terraincook
|
|
||||||
main.cpp
|
main.cpp
|
||||||
../scene/terraincooker_tin.cpp
|
../../scene/quantizedmeshcooker.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# what the cook promises: a tile reads back as it was written, neighbouring tiles share their border
|
||||||
|
# whatever level each is drawn at, a hole stays a hole and a wall is laid once
|
||||||
|
add_executable(meshtest
|
||||||
|
meshtest.cpp
|
||||||
|
../../scene/quantizedmeshcooker.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(meshtest PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../..")
|
||||||
|
target_link_libraries(meshtest PRIVATE zstd::libzstd)
|
||||||
|
if(NOT MSVC)
|
||||||
|
target_compile_options(meshtest PRIVATE -O2 -Wall)
|
||||||
|
endif()
|
||||||
|
add_test(NAME meshtest COMMAND meshtest)
|
||||||
|
enable_testing()
|
||||||
|
|
||||||
# the on-disk format lives with the engine, so the tool and the reader cannot drift apart
|
# the on-disk format lives with the engine, so the tool and the reader cannot drift apart
|
||||||
target_include_directories(terraincook PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../..")
|
target_include_directories(terraincook PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../..")
|
||||||
target_link_libraries(terraincook PRIVATE
|
target_link_libraries(terraincook PRIVATE zstd::libzstd)
|
||||||
zstd::libzstd
|
|
||||||
CDT::CDT
|
|
||||||
)
|
|
||||||
|
|
||||||
if(NOT MSVC)
|
if(NOT MSVC)
|
||||||
target_compile_options(terraincook PRIVATE -O2 -Wall)
|
target_compile_options(terraincook PRIVATE -O2 -Wall)
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ http://mozilla.org/MPL/2.0/.
|
|||||||
#include "scene/heightfieldformat.h"
|
#include "scene/heightfieldformat.h"
|
||||||
#include "scene/heightfieldreader.h"
|
#include "scene/heightfieldreader.h"
|
||||||
#include "scene/terraincooker.h"
|
#include "scene/terraincooker.h"
|
||||||
#include "scene/terraincooker_tin.h"
|
#include "scene/quantizedmeshcooker.h"
|
||||||
#include "scene/quantizedmeshformat.h"
|
#include "scene/quantizedmeshreader.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -570,18 +570,14 @@ main( int argc, char *argv[] ) {
|
|||||||
std::filesystem::path outputdirectory { "." };
|
std::filesystem::path outputdirectory { "." };
|
||||||
double step { 0.0 };
|
double step { 0.0 };
|
||||||
bool cooking { false };
|
bool cooking { false };
|
||||||
double tin_tilesize { 256.0 };
|
std::filesystem::path archive { "terrain.pak" };
|
||||||
std::uint32_t tin_lodlevels { 4 };
|
|
||||||
bool compress { true };
|
|
||||||
|
|
||||||
for( auto index { 1 }; index < argc; ++index ) {
|
for( auto index { 1 }; index < argc; ++index ) {
|
||||||
std::string const argument { argv[ index ] };
|
std::string const argument { argv[ index ] };
|
||||||
if( argument == "-step" && index + 1 < argc ) { step = std::strtod( argv[ ++index ], nullptr ); continue; }
|
if( argument == "-step" && index + 1 < argc ) { step = std::strtod( argv[ ++index ], nullptr ); continue; }
|
||||||
if( argument == "-out" && index + 1 < argc ) { outputdirectory = argv[ ++index ]; continue; }
|
if( argument == "-out" && index + 1 < argc ) { outputdirectory = argv[ ++index ]; continue; }
|
||||||
if( argument == "-cook" ) { cooking = true; continue; }
|
if( argument == "-cook" ) { cooking = true; continue; }
|
||||||
if( argument == "-tilesize" && index + 1 < argc ) { tin_tilesize = std::strtod( argv[ ++index ], nullptr ); continue; }
|
if( argument == "-archive" && index + 1 < argc ) { archive = argv[ ++index ]; continue; }
|
||||||
if( argument == "-lodlevels" && index + 1 < argc ) { tin_lodlevels = static_cast<std::uint32_t>( std::strtoul( argv[ ++index ], nullptr, 10 ) ); continue; }
|
|
||||||
if( argument == "-raw" ) { compress = false; continue; }
|
|
||||||
if( argument == "-tracks" && index + 1 < argc ) {
|
if( argument == "-tracks" && index + 1 < argc ) {
|
||||||
std::filesystem::path const trackpath { argv[ ++index ] };
|
std::filesystem::path const trackpath { argv[ ++index ] };
|
||||||
if( std::filesystem::is_directory( trackpath ) ) {
|
if( std::filesystem::is_directory( trackpath ) ) {
|
||||||
@@ -639,11 +635,16 @@ main( int argc, char *argv[] ) {
|
|||||||
std::array<double, bandcount> bandedgesum {};
|
std::array<double, bandcount> bandedgesum {};
|
||||||
std::array<std::size_t, bandcount> bandfine {};
|
std::array<std::size_t, bandcount> bandfine {};
|
||||||
|
|
||||||
terrain::tin_cooker tin_cooker;
|
// the same cooker the engine's first-run cook uses, so the two cannot drift apart
|
||||||
|
quantizedmesh::cooker cooker;
|
||||||
if( cooking ) {
|
if( cooking ) {
|
||||||
tin_cooker.configure( tin_tilesize, tin_lodlevels );
|
auto const scratch { ( outputdirectory / "terrain.cook" ).string() };
|
||||||
std::printf( "TIN cooking enabled: tile size %.0f m, %u LOD levels\n",
|
std::filesystem::create_directories( outputdirectory );
|
||||||
tin_tilesize, tin_lodlevels );
|
if( false == cooker.begin( scratch ) ) {
|
||||||
|
std::cerr << "cannot write " << scratch << "\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
std::printf( "cooking terrain mesh tiles into %s\n", ( outputdirectory / archive ).string().c_str() );
|
||||||
}
|
}
|
||||||
|
|
||||||
statistics stats;
|
statistics stats;
|
||||||
@@ -663,7 +664,13 @@ main( int argc, char *argv[] ) {
|
|||||||
|
|
||||||
parse( content, [ & ]( vertex const &A, vertex const &B, vertex const &C, std::string_view Material ) {
|
parse( content, [ & ]( vertex const &A, vertex const &B, vertex const &C, std::string_view Material ) {
|
||||||
|
|
||||||
if( cooking ) { tin_cooker.rasterize( A, B, C, Material ); }
|
if( cooking ) {
|
||||||
|
auto const corner = []( vertex const &Vertex ) {
|
||||||
|
return quantizedmesh::cooker::corner {
|
||||||
|
Vertex.x, Vertex.y, Vertex.z,
|
||||||
|
static_cast<float>( Vertex.u ), static_cast<float>( Vertex.v ) }; };
|
||||||
|
cooker.add( corner( A ), corner( B ), corner( C ), Material );
|
||||||
|
}
|
||||||
++stats.triangles;
|
++stats.triangles;
|
||||||
stats.vertices += 3;
|
stats.vertices += 3;
|
||||||
vertex const points[ 3 ] { A, B, C };
|
vertex const points[ 3 ] { A, B, C };
|
||||||
@@ -876,31 +883,42 @@ main( int argc, char *argv[] ) {
|
|||||||
writepreview( outputdirectory / "overlay.pgm", overlaypreview, previewwidth, previewheight, -1.0f );
|
writepreview( outputdirectory / "overlay.pgm", overlaypreview, previewwidth, previewheight, -1.0f );
|
||||||
|
|
||||||
if( cooking ) {
|
if( cooking ) {
|
||||||
std::filesystem::create_directories( outputdirectory );
|
std::printf( "\n-- cooking terrain mesh tiles\n" );
|
||||||
std::printf( "\n-- generating TIN tiles\n" );
|
|
||||||
|
pak::writer writer;
|
||||||
auto tiles = tin_cooker.finish();
|
auto const archivepath { ( outputdirectory / archive ).string() };
|
||||||
|
if( false == writer.open( archivepath ) ) {
|
||||||
std::printf( "Writing %zu tiles...\n", tiles.size() );
|
std::cerr << "cannot write " << archivepath << "\n";
|
||||||
std::size_t written = 0;
|
return 1;
|
||||||
|
}
|
||||||
for( auto const &tile : tiles ) {
|
auto written { true };
|
||||||
for( std::uint32_t lod = 0; lod < tile.lods.size(); ++lod ) {
|
quantizedmesh::terrain_table table;
|
||||||
char filename[ 256 ];
|
auto const sink {
|
||||||
std::snprintf( filename, sizeof( filename ), "terrain_%d_%d_lod%u.qm",
|
[ &writer, &written ](
|
||||||
tile.x, tile.z, lod );
|
quantizedmesh::tile_address const &Tile, std::vector<std::uint8_t> const &Bytes ) {
|
||||||
|
written = writer.add( quantizedmesh::tile_name( Tile ), Bytes ) && written; } };
|
||||||
std::filesystem::path const filepath = outputdirectory / filename;
|
if( ( false == cooker.finish( sink, table ) ) || ( false == written ) ) {
|
||||||
|
std::cerr << "cooking failed\n";
|
||||||
if( terrain::tin_cooker::write_tile( tile, lod, filepath.string(), compress ) ) {
|
return 1;
|
||||||
++written;
|
}
|
||||||
} else {
|
written = writer.add( quantizedmesh::tablename, quantizedmesh::write_table( table ) ) && written;
|
||||||
std::fprintf( stderr, "Failed to write %s\n", filename );
|
if( ( false == writer.close() ) || ( false == written ) ) {
|
||||||
}
|
std::cerr << "cannot finish " << archivepath << "\n";
|
||||||
}
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto const &report { cooker.result() };
|
||||||
|
std::printf( " %zu triangles laid in %zu tiles of %.0f m over %u levels\n",
|
||||||
|
report.triangles, report.tiles, cooker.finesttile(), report.levels );
|
||||||
|
std::printf( " %zu vertices, %llu kB, %zu triangles refused\n",
|
||||||
|
report.vertices, static_cast<unsigned long long>( writer.bytes() / 1024 ), report.refused );
|
||||||
|
for( std::uint32_t level = 0; level < table.levels; ++level ) {
|
||||||
|
std::printf( " level %u: tile %.0f m, off by %.3f m\n",
|
||||||
|
level, table.tilesize( level ), table.error( level ) );
|
||||||
|
}
|
||||||
|
for( auto const &material : table.materials ) {
|
||||||
|
std::printf( " material %-40s one repeat every %6.2f m\n", material.name.c_str(), material.repeat );
|
||||||
}
|
}
|
||||||
|
|
||||||
std::printf( "Wrote %zu quantized mesh files to %s\n", written, outputdirectory.string().c_str() );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::printf( "\n previews written to %s (%zux%zu, 1 px = %.0f m)\n",
|
std::printf( "\n previews written to %s (%zux%zu, 1 px = %.0f m)\n",
|
||||||
|
|||||||
221
tools/terraincook/meshtest.cpp
Normal file
221
tools/terraincook/meshtest.cpp
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
// What the cook promises, checked on a terrain made up for the purpose: a tile reads back as it was
|
||||||
|
// written, two tiles sharing a border agree on it whatever level each is drawn at, a hole the scenery
|
||||||
|
// left stays a hole, a wall is ground and is laid exactly once, and the tiling of a material is
|
||||||
|
// measured off the source rather than guessed.
|
||||||
|
//
|
||||||
|
// Run it with no arguments; it writes its files to a temporary directory and removes them.
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <set>
|
||||||
|
#include <system_error>
|
||||||
|
#include <map>
|
||||||
|
#include <tuple>
|
||||||
|
#include <set>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "scene/quantizedmeshcooker.h"
|
||||||
|
#include "scene/quantizedmeshreader.h"
|
||||||
|
|
||||||
|
namespace qm = quantizedmesh;
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
void check( bool ok, char const *what ) {
|
||||||
|
if( !ok ) { std::printf( "FAIL: %s\n", what ); ++failures; }
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
auto const directory = std::filesystem::temp_directory_path() / "eu07-meshtest";
|
||||||
|
std::error_code ignored;
|
||||||
|
std::filesystem::create_directories( directory, ignored );
|
||||||
|
std::string const scratch = ( directory / "terrain.cook" ).string();
|
||||||
|
std::string const archive = ( directory / "terrain.pak" ).string();
|
||||||
|
|
||||||
|
qm::cooker cooker;
|
||||||
|
cooker.quiet();
|
||||||
|
check( cooker.begin( scratch, 2000 ), "begin" );
|
||||||
|
|
||||||
|
auto const height = []( double x, double z ) {
|
||||||
|
return 20.0 * std::sin( x / 300.0 ) * std::cos( z / 250.0 ) + 0.01 * x;
|
||||||
|
};
|
||||||
|
// a 1200 x 1200 m field on a 10 m grid, with a square hole and one big triangle
|
||||||
|
double const step = 10.0;
|
||||||
|
int const cells = 120;
|
||||||
|
for( int ix = 0; ix < cells; ++ix ) {
|
||||||
|
for( int iz = 0; iz < cells; ++iz ) {
|
||||||
|
double const x0 = -600.0 + ix * step, x1 = x0 + step;
|
||||||
|
double const z0 = -600.0 + iz * step, z1 = z0 + step;
|
||||||
|
// a hole in the middle, as a scenery leaves where water or a building is
|
||||||
|
if( ( x0 >= -100.0 ) && ( x1 <= 100.0 ) && ( z0 >= -100.0 ) && ( z1 <= 100.0 ) ) { continue; }
|
||||||
|
char const *material = ( x0 < 0.0 ) ? "grass" : "gravel";
|
||||||
|
auto const corner = [&]( double x, double z ) {
|
||||||
|
return qm::cooker::corner{ x, height( x, z ), z,
|
||||||
|
static_cast<float>( x / 25.0 ), static_cast<float>( z / 25.0 ) };
|
||||||
|
};
|
||||||
|
cooker.add( corner( x0, z0 ), corner( x1, z0 ), corner( x1, z1 ), material );
|
||||||
|
cooker.add( corner( x0, z0 ), corner( x1, z1 ), corner( x0, z1 ), material );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// an upright wall, as the side of an embankment is. it is ground too, and must be laid - once,
|
||||||
|
// although it lies exactly along the border between two tiles (z = -512)
|
||||||
|
for( int piece = 0; piece < 4; ++piece ) {
|
||||||
|
double const xa = 100.0 + piece * 20.0, xb = xa + 20.0;
|
||||||
|
cooker.add( { xa, 0.0, -512.0, 0.f, 0.f }, { xb, 0.0, -512.0, 1.f, 0.f }, { xb, 8.0, -512.0, 1.f, 1.f }, "wall" );
|
||||||
|
cooker.add( { xa, 0.0, -512.0, 0.f, 0.f }, { xb, 8.0, -512.0, 1.f, 1.f }, { xa, 8.0, -512.0, 0.f, 1.f }, "wall" );
|
||||||
|
}
|
||||||
|
// one big triangle spanning many tiles, as 3ds Max terrain has
|
||||||
|
cooker.add( { -600.0, -5.0, 700.0, 0.f, 0.f }, { 600.0, -5.0, 700.0, 24.f, 0.f }, { 0.0, -5.0, 1100.0, 12.f, 16.f }, "grass" );
|
||||||
|
|
||||||
|
pak::writer writer;
|
||||||
|
check( writer.open( archive ), "pak open" );
|
||||||
|
qm::terrain_table table;
|
||||||
|
std::map<std::int64_t, qm::tile_address> written;
|
||||||
|
auto const sink = [&]( qm::tile_address const &tile, std::vector<std::uint8_t> const &bytes ) {
|
||||||
|
written.emplace( qm::tile_key( tile ), tile );
|
||||||
|
check( writer.add( qm::tile_name( tile ), bytes ), "pak add" );
|
||||||
|
};
|
||||||
|
check( cooker.finish( sink, table ), "finish" );
|
||||||
|
auto const tabletext = qm::write_table( table );
|
||||||
|
check( writer.add( qm::tablename, tabletext ), "table add" );
|
||||||
|
check( writer.close(), "pak close" );
|
||||||
|
|
||||||
|
auto const &report = cooker.result();
|
||||||
|
std::printf( "added %zu, laid %zu triangles in %zu tiles, %u levels, %zu vertices, %zu kB, refused %zu\n",
|
||||||
|
cooker.added(), report.triangles, report.tiles, report.levels, report.vertices,
|
||||||
|
report.bytes / 1024, report.refused );
|
||||||
|
check( cooker.refusals().empty(), "nothing refused: a wall is ground too" );
|
||||||
|
check( report.levels >= 3, "quadtree deeper than the root" );
|
||||||
|
|
||||||
|
qm::reader reader;
|
||||||
|
check( reader.open( archive ), "reader open" );
|
||||||
|
check( reader.table().materials.size() == 3, "grass, gravel and the wall" );
|
||||||
|
for( auto const &material : reader.table().materials ) {
|
||||||
|
std::printf( " material %s, one repeat every %.2f m\n", material.name.c_str(), material.repeat );
|
||||||
|
}
|
||||||
|
for( auto const &material : reader.table().materials ) {
|
||||||
|
if( material.name != "wall" ) { check( std::abs( material.repeat - 25.0 ) < 0.5, "the tiling was measured off the source" ); }
|
||||||
|
}
|
||||||
|
check( reader.table().levels == report.levels, "levels agree" );
|
||||||
|
for( std::uint32_t level = 0; level < reader.table().levels; ++level ) {
|
||||||
|
std::printf( " level %u error %.3f m, tile %.0f m\n", level,
|
||||||
|
reader.table().error( level ), reader.table().tilesize( level ) );
|
||||||
|
}
|
||||||
|
check( reader.table().error( reader.table().levels - 1 ) == 0.f, "the finest level is the ground as drawn" );
|
||||||
|
check( reader.table().error( 0 ) > reader.table().error( reader.table().levels - 2 ), "a coarser level is further off" );
|
||||||
|
|
||||||
|
// every tile reads back, and its vertices lie inside its own square
|
||||||
|
// per level and border point, the heights each tile put there. with walls in the mesh one
|
||||||
|
// point can carry several heights, so every height of one tile has to find a match in the
|
||||||
|
// other rather than the lists being compared one to one
|
||||||
|
std::map<std::tuple<std::uint32_t,long long,long long>, std::map<std::int64_t, std::vector<double>>> bordery;
|
||||||
|
std::size_t read = 0, trianglesread = 0;
|
||||||
|
std::map<std::uint32_t, std::size_t> perlevel;
|
||||||
|
for( auto const &[ key, tile ] : written ) {
|
||||||
|
qm::reader::tile_data data;
|
||||||
|
if( !reader.read_tile( tile, data, 0.0, 0.0, 0.0 ) ) { std::printf( "FAIL: read %s\n", qm::tile_name( tile ).c_str() ); ++failures; continue; }
|
||||||
|
++read;
|
||||||
|
trianglesread += data.indices.size() / 3;
|
||||||
|
perlevel[ tile.level ] += data.indices.size() / 3;
|
||||||
|
auto const side = reader.table().tilesize( tile.level );
|
||||||
|
auto const west = reader.table().west + tile.x * side;
|
||||||
|
auto const north = reader.table().north + tile.z * side;
|
||||||
|
for( auto const &v : data.vertices ) {
|
||||||
|
double const x = data.originx + v.x, z = data.originz + v.z, y = data.originy + v.y;
|
||||||
|
check( x >= west - 0.01 && x <= west + side + 0.01, "vertex inside its tile in x" );
|
||||||
|
check( z >= north - 0.01 && z <= north + side + 0.01, "vertex inside its tile in z" );
|
||||||
|
check( y >= data.lowest - 0.01 && y <= data.highest + 0.01, "vertex inside its height range" );
|
||||||
|
// remember heights on tile borders, to compare with the neighbour later
|
||||||
|
auto const onborder = std::abs( x - west ) < 0.01 || std::abs( x - west - side ) < 0.01
|
||||||
|
|| std::abs( z - north ) < 0.01 || std::abs( z - north - side ) < 0.01;
|
||||||
|
if( onborder ) {
|
||||||
|
auto const gx = static_cast<long long>( std::llround( x * 100.0 ) );
|
||||||
|
auto const gz = static_cast<long long>( std::llround( z * 100.0 ) );
|
||||||
|
bordery[ { tile.level, gx, gz } ][ key ].push_back( y );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::printf( "read %zu tiles, %zu triangles\n", read, trianglesread );
|
||||||
|
for( auto const &[ level, count ] : perlevel ) { std::printf( " level %u: %zu triangles\n", level, count ); }
|
||||||
|
|
||||||
|
// a vertex two tiles of the same level share must decode to the same height in both
|
||||||
|
// where two tiles meet, a point on the border can carry more than one height - a wall standing
|
||||||
|
// on the ground is two surfaces at one place - and only one of them belongs to the neighbour.
|
||||||
|
// What has to agree, and is what keeps the ground from cracking, is the single-surface case
|
||||||
|
double worst = 0.0;
|
||||||
|
std::size_t shared = 0, manysurfaced = 0;
|
||||||
|
for( auto const &[ where, pertile ] : bordery ) {
|
||||||
|
if( pertile.size() < 2 ) { continue; }
|
||||||
|
++shared;
|
||||||
|
auto simple = true;
|
||||||
|
for( auto const &[ which, heights ] : pertile ) { if( heights.size() != 1 ) { simple = false; } }
|
||||||
|
if( false == simple ) { ++manysurfaced; continue; }
|
||||||
|
auto const first = pertile.begin()->second.front();
|
||||||
|
for( auto const &[ which, heights ] : pertile ) {
|
||||||
|
worst = std::max( worst, std::abs( heights.front() - first ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::printf( "%zu of %zu shared border points carry more than one surface\n", manysurfaced, shared );
|
||||||
|
std::printf( "%zu border points shared by two tiles\n", shared );
|
||||||
|
std::printf( "worst height disagreement on a shared border: %.4f mm\n", worst * 1000.0 );
|
||||||
|
check( worst < 0.01, "shared border vertices agree within a centimetre" );
|
||||||
|
|
||||||
|
// the coarse levels must actually be coarser
|
||||||
|
if( perlevel.size() >= 2 ) {
|
||||||
|
auto const finest = perlevel.rbegin()->second;
|
||||||
|
auto const next = std::next( perlevel.rbegin() )->second;
|
||||||
|
check( next < finest, "a coarser level has fewer triangles" );
|
||||||
|
}
|
||||||
|
|
||||||
|
// the wall along the tile border must be there, and exactly once: 8 triangles, not 16
|
||||||
|
{
|
||||||
|
std::size_t wallpieces = 0;
|
||||||
|
std::set<std::pair<long long,long long>> wallcentres;
|
||||||
|
for( auto const &[ key, tile ] : written ) {
|
||||||
|
if( tile.level + 1 != reader.table().levels ) { continue; }
|
||||||
|
qm::reader::tile_data data;
|
||||||
|
if( !reader.read_tile( tile, data, 0.0, 0.0, 0.0 ) ) { continue; }
|
||||||
|
for( std::size_t i = 0; i + 2 < data.indices.size(); i += 3 ) {
|
||||||
|
bool wall = true;
|
||||||
|
for( int c = 0; c < 3; ++c ) {
|
||||||
|
auto const &v = data.vertices[ data.indices[ i + c ] ];
|
||||||
|
if( std::abs( data.originz + v.z + 512.0 ) > 0.01 ) { wall = false; }
|
||||||
|
}
|
||||||
|
if( wall ) {
|
||||||
|
++wallpieces;
|
||||||
|
double cx = 0.0, cy = 0.0;
|
||||||
|
for( int c = 0; c < 3; ++c ) {
|
||||||
|
cx += data.originx + data.vertices[ data.indices[ i + c ] ].x;
|
||||||
|
cy += data.originy + data.vertices[ data.indices[ i + c ] ].y;
|
||||||
|
}
|
||||||
|
wallcentres.insert( { std::llround( cx * 100.0 ), std::llround( cy * 100.0 ) } );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::printf( "wall triangles on the tile border: %zu (%zu distinct)\n", wallpieces, wallcentres.size() );
|
||||||
|
check( wallpieces > 0, "the wall on the border was laid" );
|
||||||
|
check( wallcentres.size() == wallpieces, "no piece of the wall was laid twice" );
|
||||||
|
}
|
||||||
|
|
||||||
|
// a hole stays a hole: nothing may be drawn in the middle of the map at the finest level
|
||||||
|
std::size_t inhole = 0;
|
||||||
|
for( auto const &[ key, tile ] : written ) {
|
||||||
|
if( tile.level + 1 != reader.table().levels ) { continue; }
|
||||||
|
qm::reader::tile_data data;
|
||||||
|
if( !reader.read_tile( tile, data, 0.0, 0.0, 0.0 ) ) { continue; }
|
||||||
|
for( std::size_t i = 0; i + 2 < data.indices.size(); i += 3 ) {
|
||||||
|
double cx = 0.0, cz = 0.0;
|
||||||
|
for( int c = 0; c < 3; ++c ) {
|
||||||
|
cx += data.originx + data.vertices[ data.indices[ i + c ] ].x;
|
||||||
|
cz += data.originz + data.vertices[ data.indices[ i + c ] ].z;
|
||||||
|
}
|
||||||
|
cx /= 3.0; cz /= 3.0;
|
||||||
|
if( cx > -95.0 && cx < 95.0 && cz > -95.0 && cz < 95.0 ) { ++inhole; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::printf( "triangles inside the hole: %zu\n", inhole );
|
||||||
|
check( inhole == 0, "the hole was not filled in" );
|
||||||
|
|
||||||
|
std::filesystem::remove_all( directory, ignored );
|
||||||
|
std::printf( failures ? "\n%d checks failed\n" : "\nall checks passed\n", failures );
|
||||||
|
return failures ? 1 : 0;
|
||||||
|
}
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Checks that terrain tiles at neighbouring levels meet without gaps.
|
|
||||||
|
|
||||||
The renderer picks each tile's level from its distance at the last rescan, and morphs every
|
|
||||||
vertex towards the next level as the camera moves (rendering/terrainclipmap.vert). Two
|
|
||||||
neighbours at levels L and L+1 share their edge exactly when, all along it, the finer tile
|
|
||||||
has finished morphing and the coarser one has not started. This walks random camera
|
|
||||||
positions, lets the camera drift up to a rescan's distance from where the levels were
|
|
||||||
chosen, and counts every edge vertex where that does not hold.
|
|
||||||
|
|
||||||
The constants mirror rendering/terrainstatus.cppm; change them together. Exits non-zero
|
|
||||||
when a gap is found.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import math
|
|
||||||
import random
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# finest ranges tried, in tile sides: terrain_finest_tiles is the minimum, and the screen
|
|
||||||
# can push it further (terrain_finest_range)
|
|
||||||
FINEST_TILES = (4.0, 5.7, 11.3)
|
|
||||||
RESCAN_TILES = 0.25 # terrain_rescan_tiles
|
|
||||||
MORPH_BAND = 0.2 # terrain_morph_band
|
|
||||||
LEVELS = 6
|
|
||||||
|
|
||||||
|
|
||||||
def check(tilesize, step, trials, reach, finest_tiles):
|
|
||||||
finest = finest_tiles * tilesize
|
|
||||||
|
|
||||||
def level_range(level):
|
|
||||||
return finest * 2 ** level
|
|
||||||
|
|
||||||
def level_for(distance):
|
|
||||||
if distance <= finest:
|
|
||||||
return 0
|
|
||||||
return min(int(math.log2(distance / finest)) + 1, LEVELS - 1)
|
|
||||||
|
|
||||||
def nearest(x, z, tilex, tilez):
|
|
||||||
nx = min(max(x, tilex * tilesize), (tilex + 1) * tilesize)
|
|
||||||
nz = min(max(z, tilez * tilesize), (tilez + 1) * tilesize)
|
|
||||||
return math.hypot(nx - x, nz - z)
|
|
||||||
|
|
||||||
def morph(level, distance):
|
|
||||||
if level + 1 >= LEVELS:
|
|
||||||
return 0.0
|
|
||||||
end = level_range(level) - RESCAN_TILES * tilesize
|
|
||||||
begin = end - MORPH_BAND * level_range(level)
|
|
||||||
return min(max((distance - begin) / (end - begin), 0.0), 1.0)
|
|
||||||
|
|
||||||
gaps = 0
|
|
||||||
example = ""
|
|
||||||
for _ in range(trials):
|
|
||||||
scanx, scanz = random.uniform(-5000, 5000), random.uniform(-5000, 5000)
|
|
||||||
drift, heading = random.uniform(0, RESCAN_TILES * tilesize), random.uniform(0, 2 * math.pi)
|
|
||||||
camx, camz = scanx + drift * math.cos(heading), scanz + drift * math.sin(heading)
|
|
||||||
basex, basez = math.floor(camx / tilesize), math.floor(camz / tilesize)
|
|
||||||
for tilex in range(basex - reach, basex + reach):
|
|
||||||
for tilez in range(basez - reach, basez + reach):
|
|
||||||
here = level_for(nearest(scanx, scanz, tilex, tilez))
|
|
||||||
for stepx, stepz in ((1, 0), (0, 1)):
|
|
||||||
there = level_for(nearest(scanx, scanz, tilex + stepx, tilez + stepz))
|
|
||||||
if here == there:
|
|
||||||
continue
|
|
||||||
if abs(here - there) > 1:
|
|
||||||
gaps += 1
|
|
||||||
continue
|
|
||||||
fine, coarse = min(here, there), max(here, there)
|
|
||||||
count = int(tilesize / step) >> fine
|
|
||||||
for index in range(count + 1):
|
|
||||||
along = index * tilesize / count
|
|
||||||
if stepx:
|
|
||||||
x, z = (tilex + 1) * tilesize, tilez * tilesize + along
|
|
||||||
else:
|
|
||||||
x, z = tilex * tilesize + along, (tilez + 1) * tilesize
|
|
||||||
distance = math.hypot(x - camx, z - camz)
|
|
||||||
# an odd vertex of the finer tile is not on the coarser grid and has
|
|
||||||
# to have finished its move; the coarser edge must not be moving yet
|
|
||||||
if (index % 2 == 1 and morph(fine, distance) < 1.0) or morph(coarse, distance) > 0.0:
|
|
||||||
gaps += 1
|
|
||||||
if not example:
|
|
||||||
example = (f"camera {camx:.0f},{camz:.0f}, levels {fine}/{coarse}, "
|
|
||||||
f"distance {distance:.0f} m")
|
|
||||||
return gaps, example
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
random.seed(1)
|
|
||||||
failed = False
|
|
||||||
for finest_tiles in FINEST_TILES:
|
|
||||||
for tilesize, step, trials, reach in ((128, 1.0, 150, 60), (256, 2.0, 150, 60), (12800, 100.0, 30, 8)):
|
|
||||||
gaps, example = check(tilesize, step, trials, reach, finest_tiles)
|
|
||||||
print(f"finest {finest_tiles} tiles, tile {tilesize} m, grid {step} m: {gaps} gaps"
|
|
||||||
+ (f" (e.g. {example})" if gaps else ""))
|
|
||||||
failed = failed or gaps > 0
|
|
||||||
return 1 if failed else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
Reference in New Issue
Block a user