17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-18 00:59:18 +02:00
Commit Graph

7 Commits

Author SHA1 Message Date
maj00r
b877bc0772 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.
2026-09-20 08:04:46 +02:00
maj00r
5f34d68e6f WIP: TIN terrain system - 95% complete
Replaces heightfield with Cesium Quantized Mesh format (TIN).
Full replacement, not hybrid - TIN is the only terrain format.

Implemented:
- Format: quantizedmeshformat.h, quantizedmeshreader.h
- Triangulator: terraincooker_tin.{h,cpp} using CDT
- Cooker: terraincook now generates TIN by default
- Loader: terraintileloader fully rewritten for TIN
- GPU pool: terraintilepool_tin (VBO/IBO per tile)
- Renderer interface: terrainclipmap.cppm updated
- Shaders: terrainclipmap_tin.{vert,frag}
- Documentation: 5 comprehensive markdown files

Remaining:
- terrainclipmap.cpp render() method (partial, needs completion)
- Build testing and integration
- See TIN_INTEGRATION_GUIDE.md for next steps

Stats: 2700+ lines new code, ~50% vertex reduction expected
2026-09-17 23:02:13 +02:00
maj00r
951a54cde7 Bake only the ground a heightfield replaces, at a step suited to each triangle
Three changes to what goes into a baked heightfield and how it meets the geometry drawn
beside it.

The heightfield held every triangle laid in it, including those it does not replace and
that are therefore drawn as geometry too - so they were drawn twice, and a roof or a
bridge deck left in it bent the ground. Now the first pass lays everything and decides
what is replaced; the second lays the replaced triangles together with the neighbours
that support them - the owners of the corner samples of the cells around them, without
which those cells are not drawn - and only that is written. A first attempt that
repeated passes, dropping whatever bordered a hole, eroded the whole terrain away one
ring per pass: the edge of the ground is always a hole. The replaced triangles stay
replaced in the second pass, since their supports are all there and they have fewer
rivals. The triangles bake no longer keep in memory; they wait in a spill file beside
the heightfield while the scenery parses.

Triangles were refused for an edge longer than 200 m, meant for a water plane under the
whole map. On baltyk and tomaszewo that refused thousands of ordinary fields of a
hand-modelled terrain, and nothing there comes near a whole-map plane. Only triangles
standing on their edge - walls, trees, fences - are refused now. The bake log says why
the triangles not replaced are not: standing on edge, too small, under other surfaces,
next to a hole.

Hand-modelled terrain has triangles hundreds of metres long, elevation-model terrain
small ones along the line, and one grid step either loses the detail or samples a field
every metre. A bake now writes three heightfields a factor of four apart -
<scenario>.ehf, <scenario>.1.ehf, <scenario>.2.ehf - and each triangle goes to the
coarsest one that still puts sixteen samples along its longest edge, and to every
coarser one too, so the coarsest holds all the ground and a triangle at the edge of a
finer field can be replaced by a coarser field beneath it. All of them are picked up
together, and a missing or stale one sends the whole set to be baked again.

Where fields overlap each other or the geometry, the depth test decides: the terrain is
drawn with a polygon offset, negative for the reversed depth buffer and larger for each
coarser field, instead of being lowered by 2 cm - so geometry lying on the ground wins
at any distance and on any slope, and a finer field wins over a coarser one.

On tomaszewo the heightfields went from 176 MB to 10 MB, the load that bakes them from
5:16 to 3:11 and its peak memory from 15.7 GB to 8.5 GB - the scenery alone takes 8.0 -
while the triangles replaced went from 37,130 to 40,402. On baltyk from 19 MB to 2 MB,
with 6,670 triangles replaced against 4,040.
2026-09-17 17:24:25 +02:00
maj00r
d2a5f284e3 Draw terrain one call per level, from texture arrays, culled to the view
Each resident tile had a height texture and a material texture of its own, uploaded with
glTexImage2D, and was drawn once per material it held with every other material's
fragments discarded, all of them whether in view or not. Thousands of draws a frame.

Now the tiles of a level live in one pair of texture arrays (terrain_tile_pool), a layer
per tile. A pool starts at sixteen layers and doubles when full, copying what it holds on
the gpu, so a level takes only the memory its tiles need; uploads go through a pixel
buffer orphaned for each tile. The ground textures of every material are gathered into
one sRGB array with mipmaps (terrain_ground) - drawn into it layer by layer by a small
shader once the engine has each texture ready, since the scenery's textures are
compressed and cannot be blitted - with a table of placeholder colours and measured
tiling beside it.

Each frame the tiles in range are tested against the frustum by bounding sphere and
gathered per level into a buffer texture of instance data, and each level is drawn in
one instanced call. The fragment shader reads the four samples around it, drops the cell
when any of them carries no ground - exactly the rule the bake already assumed - and
blends the ground textures of their materials by distance, so material boundaries
follow the samples smoothly instead of stepping along triangle edges.

State the copy pass changes is captured and put back, and the texture unit cache is
reset after raw binds, so the rest of the renderer finds things as it left them.

On baltyk: all 30 materials land in the array, 663 of the 1120 tiles in range are drawn
after culling, and a frame is at most six terrain draws. The residency line in the log
now says how many tiles in range were drawn.
2026-09-17 16:28:43 +02:00
maj00r
be4d691ae2 Choose terrain levels from what the screen can resolve
Levels changed at fixed distances - 512 m, 1 km, 2 km and on - whatever the window, the
field of view or the zoom. Now a level is used out to the distance where one of its sample
spacings covers terraindetail pixels on screen, 3 by default, which follows from the
viewport height and the vertical field of view with the zoom applied; each level doubles
the spacing, so each doubles the distance. Zooming in or a taller window pushes detail
further, a coarser setting pulls it in. The finest range never drops below four tile
sides, which the morphing needs to close the seams.

Levels and morphing both follow the finest range the last scan used, so they always agree,
and a change of more than a percent - a zoom, a resize - triggers a rescan.
tools/terraincook/seamcheck.py now also tries finest ranges of 5.7 and 11.3 tiles, and
finds no gap in any of them.

terraindetail is in the ini and on a slider in the debug panel; the panel's level table
and the residency line in the log show the resulting ranges. On baltyk the current window
works out to 484 m, so the 512 m minimum applies and nothing changes by default; a
scenery setting terraindetail 1 got 1349 m, and the samples on the gpu went from 3 MB to
10 MB with it.
2026-09-17 16:11:41 +02:00
maj00r
37ab4a35b3 Morph terrain levels into each other so neighbours meet without cracks
Tiles drawn at the same level met exactly, but a finer tile beside a coarser one has
samples on their shared edge that the coarser one skips, and the ground between them
could open up. This closes the gap the standard way (CDLOD): each vertex slides towards
the next coarser level as its distance nears the end of its level's range, so that by the
end of the band a tile is, vertex for vertex, the coarser tile beyond it. Levels also stop
popping when they change.

The mip chain is point sampled, so the next level's grid is this level's even samples. An
odd vertex slides onto its even neighbour and takes its height, and the grid, whose
diagonals all run the same way at every level, collapses exactly into the coarser
triangulation. No second texture and no neighbour information is needed; the morph is
computed per vertex from its distance to the camera, the same distance the level is
chosen by.

Two things the geometry demands, both settled by tools/terraincook/seamcheck.py, which
walks random camera positions over 128 m, 256 m and 12.8 km tiles and counts every edge
vertex where the finer side has not finished morphing or the coarser side has started:

- the band ends a rescan's worth of travel (a quarter tile) short of the level's range.
  Levels are chosen where the camera stood at the last rescan, the morph follows it every
  frame, and a band ending at the range itself left gaps at every level boundary;
- the band is a fifth of the range wide, and level ranges are counted in tiles (four for
  the finest level) rather than fixed metres, which keeps the coarser tile's band clear of
  any edge a finer tile reaches, whatever the tile size.

The script finds no gap with these values. In the game on baltyk, a low view differs from
the unmorphed shader only in a strip of distant ground at the horizon, and nearer than
the first band the picture is identical.

The cooker's comment claimed tile seams were identical at every level; that held only
for neighbours at the same level, and now says so.
2026-09-17 15:58:05 +02:00
maj00r
2660d14565 Stream the scenery: a baked terrain heightfield, a logic container, and prefabs
Today's work on the binary scene format plan (binary-scene-format-plan.md), from the
first measurement to a terrain that bakes itself on first run and streams from a
thread. The step-by-step history is kept on backup/scene-streaming-format-presquash-0916.

Measuring first. -loadprofile times a scenery load by stage and by node kind and reports
node counts and resident memory. On l204 terrain is 95% of the bytes and 99.8% of the
nodes; on quarkmce2007 vehicles, not models, dominate what is left. The terrain turned
out to be a heightfield in all but name, a ribbon covering 12% of its bounding box.

The heightfield. scene/heightfieldformat.h is the .ehf layout: Morton-ordered tiles
of 128 samples with one shared edge sample, a point-sampled mip chain, zstd per tile,
a nodata sentinel, a material palette with a measured tiling per material, and a stamp
of the rules that decided what counted as terrain. scene/heightfieldreader.h reads it
for the tool, the renderer and the loader alike, and scene/terraincooker.h writes it,
shared by the terraincook tool and the engine so a first-run bake is the file the tool
would have made. The cooker rasterises every triangle where it is drawn (through
scene::place_in_world, the placement the region applies too), keeps an upper surface
laid within half a metre over another and the lower of two further apart, and then
asks each triangle whether the heightfield shows it: laid, reaching a sample, keeping
nearly all it claimed, and with the ground around it drawn at every level out to 2 km.

Baking on first run. simulation/terrainbake bakes <scenario>.ehf the first time a
scenery is opened, choosing the grid step from the first 200,000 vertices, and on later
loads leaves out the triangles its node table says the heightfield shows. Everything
else - trees, grass tufts, gantries, bridge decks - is drawn as written, so a bad bake
can at worst draw something twice, and the heightfield sits 2 cm under the triangles so
the original wins there. Nodes are keyed by file, line, include parameters and
placement; an edited file changes its keys and those nodes are drawn again. A bake under
older rules, or without its table, is thrown away and redone. A launcher copy named
$scenario shares the scenario's files. -nobaketerrain shows the triangles as written.
A scenery can still name hand-cooked fields, several at different steps.

Drawing and streaming. rendering/terrainclipmap draws resident tiles at the mip level
their distance earns, deriving the grid from gl_VertexID and texturing in world space
with the scenery's own materials. rendering/terraintileloader reads and decodes tiles on
a thread of its own; the renderer rescans what is in range only after moving a quarter
of a tile, asks for what is missing nearest first, uploads at most 32 tiles a frame,
drops arrivals the camera has moved past, and keeps drawing a tile at the level it has
until the right one arrives. The terrain has its own range, terrainrange in the ini and
a slider in the debug panel, 20 km by default and independent of basedrawrange; the far
plane of an ordinary view is pushed out to it. The debug panel shows the range, tiles in
view, on the gpu, wanted and queued, and how many sit at each level.

Events. The event queue moved out of the events into a binary heap with a deterministic
tie-break, and an event is handed its activator instead of storing it, so basic_event
is a definition and nothing else.

The logic container. scene/logicformat.h and scene/logiccontainer write a scenery's
events, their targets and sibling chains as flat, deterministic arrays with a schema
hash that refuses a mismatched build. simulation/logiccook cooks it from the loaded
state whenever it is missing, stale or unreadable, reads it straight back to check it,
and resolves each target to a position in the registry its event kind searches.
-cooklogic forces a cook, -verifylogic only checks. Nothing reads it at load yet.

Prefabs. scene/prefab describes a signal by the aspects it can show - lights, speeds,
animation channels, a sound - instead of pasting an .inc per variant, and expands an
instance to ordinary scenery text. Tried on a light signal and a shape signal.

Screenshots without a person present: -shot x,y,z[,yaw[,pitch]], -shotsettle, -shotstay.
2026-09-16 23:18:09 +02:00