A clean, modular implementation of Eric Lengyel's Transvoxel algorithm β seamless level-of-detail (LOD) triangulation of a voxel density field β driven by an octree and built for large, editable, real-time landscapes.
|
|
Unity 6000.0+. URP is a dependency and the Package Manager pulls it in for you.
Window βΈ Package Manager βΈ + βΈ Install package from git URL and paste:
https://github.com/reromanlee/Transvoxel.git
or add it to Packages/manifest.json by hand:
{
"dependencies": {
"com.reromanlee.transvoxel": "https://github.com/reromanlee/Transvoxel.git"
}
}That tracks main. For anything you intend to ship, pin a release tag instead. Tags are the
bare version with no v prefix β pick one from the
releases page:
https://github.com/reromanlee/Transvoxel.git#2.0.0
Notes worth knowing:
- Unity shells out to Git for this: it needs a Git client 2.14.0 or newer on your
PATH(and Git LFS if you fork the repo and store assets with it). "No 'git' executable was found" in the Package Manager means exactly that. - A Git-installed package is read-only β edit a local clone instead (below).
- Nothing updates on its own. An unpinned URL gets an Update button in the Package
Manager that re-resolves to the latest commit on
main; a pinned#tagstays put until you change the tag, which is the point of pinning.
Every release attaches
com.reromanlee.transvoxel-<version>.tgz (about 0.8 MB). No Git needed, and the version is
frozen.
- Download the
.tgzfrom the release. - Put it inside your project β a
Packages/folder next tomanifest.jsonworks well β so the path stays valid for everyone who clones the repo. - Window βΈ Package Manager βΈ + βΈ Install package from tarball and pick the file.
Unity records a file: path to the archive, e.g.:
{
"dependencies": {
"com.reromanlee.transvoxel": "file:com.reromanlee.transvoxel-2.0.0.tgz"
}
}A relative file: path resolves against the project's Packages folder, so the entry above
finds a tarball sitting directly in Packages/. Keep the .tgz where it is β Unity reads it
whenever it resolves packages, so moving or deleting the file breaks the project, and an
absolute path outside the project works for you and for nobody else who opens it.
Point a file: path at a checkout and your edits are live β no reinstall between changes:
{
"dependencies": {
"com.reromanlee.transvoxel": "file:../../Transvoxel"
}
}The path is relative to the project's Packages folder. Unity writes .meta files into the
clone, which is what you want when you are working on the package.
- The demo is a sample: Window βΈ Package Manager βΈ Transvoxel βΈ Samples βΈ Import. See Quick start below.
- To run the package's own tests, opt in from your project's
Packages/manifest.json:"testables": [ "com.reromanlee.transvoxel" ](see Tests).
The pipeline is four independent layers. Each one only knows about the layer below it through a tiny interface, so you can replace any of them on its own.
| Layer | Folder | Responsibility | Key type |
|---|---|---|---|
| Density β where is there ground? | Runtime/Density/ |
Answers SampleVoxel(x,y,z) β 0..1. Two stacked layers: player edits (A) over procedural landscape (B). A parallel sparse layer answers SampleMaterial(x,y,z) β palette id. |
IDensitySource, LayeredDensitySource, VoxelMaterialLayer |
| Meshing (CPU) β how do we triangulate a chunk? | Runtime/Meshing/ |
Turns a sampled chunk into vertices/normals/UVs using the Transvoxel tables. Regular cells + transition cells. | TransvoxelMesher, TransvoxelTransitionMesher |
| Meshing (GPU) β the same, in compute shaders | Runtime/Gpu/, Runtime/Resources/TransvoxelCompute.compute |
Density (noise + edit overlay) and the whole triangulation run in three compute kernels; triangles stream back via async readback. | GpuChunkBuilder, TransvoxelGpuTables |
| Octree β what should exist right now? | Runtime/Octree/ |
Pure function of viewer position β the set of chunks and which faces need transition cells. | TerrainOctree β ChunkDrawCommand |
| Orchestrator β wire it together | Runtime/TransvoxelTerrain.cs |
Diffs the octree's wishes against the live scene, feeds a distance-prioritized build queue (CPU workers or GPU dispatches), uploads finished meshes under a per-frame time budget. | TransvoxelTerrain |
The raw lookup tables translated from Lengyel's C++ live in
Runtime/TransvoxelDataTables.cs (Concept.txt #3).
- Window βΈ Package Manager βΈ Transvoxel βΈ Samples βΈ Import the Interactive Demo.
- Open
TransvoxelDemo.unityfrom the imported folder and press Play.
The scene comes wired: camera, sun, terrain, a four-material palette with albedo and height maps, and the overlay. You can:
- RMB drag to look, WASD + Q/E to fly (Shift = faster) β or a gamepad's sticks,
- LMB to dig, Shift + LMB to build, 1β4 (or the bumpers) to pick the material,
- drive every terrain setting live from the panel: brush, materials and blend, view distance, LOD levels and split factor, voxel size, shading, LOD tint, fades, triplanar, parallax occlusion, meshing backend and collider LOD.
The sample needs the Input System package, with Project Settings βΈ Player βΈ Active Input Handling set to Input System Package or Both. (The package itself has no such requirement β only the sample does.)
Importing copies the sample. Package Manager snapshots
Samples~/intoAssets/Samples/Transvoxel/<version>/, and later package updates never touch that copy β so after updating the package, press Import again to pick up changes to the demo (it will ask before overwriting). Close the demo scene first: the re-import replaces the scene file too. Edits you make insideAssets/Samples/are yours, and are overwritten by a re-import.
Using the terrain in your own scene needs none of this; see below.
- Add
TransvoxelTerrainto a GameObject. - Assign a viewer transform (defaults to
Camera.main). - Assign a settings asset β create one via Assets βΈ Create βΈ Transvoxel βΈ Terrain Settings β or leave it empty for sensible defaults.
// Terraforming from your own tools:
terrain.Terraform(worldPoint, radius: 5f, strength: 0.9f, build: false); // dig
terrain.RaycastDensity(cameraRay, 400f, out Vector3 hit); // find the surfaceEvery field on the settings asset is applied while the game is running β change the view
distance, LOD count, noise frequency/height, iso level, etc. in the Inspector during Play and
the terrain rebuilds itself the next frame (player edits are preserved). This is wired through
TransvoxelSettings.Changed; if you change a field from code, call settings.NotifyChanged().
Notable knobs (Concept.txt #4, #6):
- meshingBackend β
CpuThreads(worker threads, runs everywhere) orGpuCompute(compute shaders, see below). Switchable live, edits preserved. - maxLodLevels β LOD levels above LOD0 (e.g.
4β LOD0..LOD4). - viewDistance β meters beyond which nothing is generated.
- lodSplitFactor β higher = more detail further away (more chunks).
- smoothShading β smooth shared-vertex normals vs. flat low-poly triangles.
- colliderMaxLod β which LODs get a
MeshCollider(baked off the main thread). - chunkFadeInSeconds / edgeFadeFraction β stipple cross-fade (see Dithered fading below): how long new chunks take to dither in, and the per-pixel dissolve band at the draw-distance edge.
- materialPalette / materialBlendSharpness β the terrain's material set and how sharply neighbouring voxel materials cut into each other (see Voxel materials below). The sharpness is a live shader global β drag it during Play, nothing rebuilds. The palette asset itself carries the triplanar and parallax occlusion switches (see Triplanar and parallax occlusion); both apply live too.
- meshApplyBudgetMs β the main-thread time slice per frame for uploading finished meshes. Bursts of hundreds of chunks (teleport, high-speed flight) spread over frames instead of spiking one.
- gpuJobsInFlight β GPU mode: chunks allowed on the GPU at once (throughput vs. VRAM).
- lodSwapLinger β smoothness window for LOD swaps: how long a replaced chunk may linger after its replacement is ready, and how long a re-meshed chunk may wait for the neighbours that changed its transition mask before swapping anyway. Raise it if you see brief holes or seams when moving fast; 0 disables both protections for minimal latency/overdraw.
All backends produce the same landscape (the GPU noise runs the same permutation table as
the CPU's FractalNoise β same seed, same terrain) and share the octree, the priority
queue, colliders and terraforming. Set meshingBackend on the settings asset:
- CpuThreads β chunks are sampled and meshed on a pool of worker tasks, one per core.
- GpuCompute β three kernels in
TransvoxelCompute.computedo the heavy lifting:CSVolumebuilds the chunk's density grid: procedural noise overridden by the player-edit bricks;CSRegularruns one thread per cell over Lengyel's tables (uploaded once as buffers β they are far too large for HLSL initializers);CSTransitionstitches the LOD seams with transition cells, one thread per face cell. Finished triangles return viaAsyncGPUReadback(two-stage: count, then exactly that many triangles), so the CPU never blocks on the GPU. Because GPU cells cannot share the paper's serial reuse decks, they emit triangle soup β which a light worker task then welds back into an indexed mesh (coincident vertices are bit-identical, so welding is an exact hash, no epsilon). GPU chunks therefore render exactly like CPU chunks: ~3Γ fewer vertices than the raw soup, vertex-cache-friendly, small uploads.
- Hybrid β CPU workers and the GPU pipeline pull from the same nearest-first queue at once: whichever processor is free builds the next chunk. Highest build throughput β ideal for teleports and very large view distances.
The edit layer never round-trips. Player-edit bricks (16Β³ voxels) live in one resident
GPU buffer pool: uploaded once, then only the bricks touched by a terraform stroke are
re-uploaded β and each chunk build sends just the few pool slot indices it overlaps. Editing
half the map costs GPU builds nothing extra per chunk. The saveable copy stays in C#
(VoxelEditLayer), exactly as before.
If the platform lacks compute shaders or async readback (or a custom DensityOverride is
active β arbitrary C# can't run on the GPU), GPU and Hybrid fall back to CpuThreads with a
console warning.
Nothing about the landscape ever pops. Every visual change runs through one screen-space Bayer-dither clip β the same technique as Unity LOD Group cross-fading:
- Fade-in (
chunkFadeInSeconds): a freshly built chunk dithers from invisible to solid. - Fade-out: a retired chunk (LOD swap, moved out of range) dithers away over the same duration β and only after its replacements are fully faded in underneath.
- Mesh-swap cross-fade: when a live chunk re-meshes (terraforming, a transition-mask change as LOD rings shift), its old surface moves onto a short-lived ghost that dithers out with the complementary stipple pattern while the new mesh dithers in β at every moment each screen pixel is drawn by exactly one of the two, so the swap is seamless: no holes, no double-brightness. Rapid re-edits keep at most one ghost per chunk.
- Draw-distance dissolve (
edgeFadeFraction): terrain fades out towardviewDistanceper pixel (driven by shader globals, not per chunk), so even a kilometers-wide coarse chunk dissolves smoothly like fog. Chunks leaving the view range are fully transparent before they are actually removed; new frontier chunks are born inside the faded band and brighten as you approach.edgeFadeCurvereshapes that falloff: the terrain bakes the curve into a small LUT (_TransvoxelEdgeFadeCurve) and the shader remaps the dither opacity through it per pixel β X = raw fade (0 at the draw distance, 1 at the viewer), Y = kept opacity. Lift the middle/left to keep near and mid LODs solid (less grain) while the far edge still dissolves. The default straight line is the plain linear ramp.
Fading needs shader support. The bundled Transvoxel/Lit Dithered shader (URP; the
default runtime material uses it automatically) implements it. The whole implementation
lives in one reusable module β
Runtime/Resources/TransvoxelDither.hlsl β
which the shader includes, and which your own URP shaders and graphs can include too. Whatever you
build with it: the fade inputs are global uniforms driven by the terrain β never
redeclare them as material properties, Properties-block entries or Blackboard properties
(the SRP Batcher would lock a per-material copy at its inspector value). Two mesh facts
the module relies on: the terrain bakes (fadeStartTime, Β±fadeDuration) into UV1
(TEXCOORD1 β what Mesh.uv2 stores; the sign marks a cross-fade ghost), and meshes
without that channel read (0,0) and render solid, so a fade-aware material is safe on
any mesh. TransvoxelShaderGlobals boots the master fade to its neutral 1 at
editor/player startup, so fade-aware materials are visible before any terrain runs.
Any graph becomes fade-aware with one Custom Function node β build it once, save the group as a Sub Graph, reuse it everywhere:
- Add a Custom Function node: Type File, Source
TransvoxelDither.hlsl(from this package), NameTransvoxelDitherFade. - Give it inputs
FadeData(Vector2),PositionWS(Vector3),ScreenPos(Vector4) and one outputAlpha(Float), then wire:- UV node, channel UV1 β
FadeData - Position node, Space World β
PositionWS - Screen Position node, Mode Raw β
ScreenPos
- UV node, channel UV1 β
- Wire
Alphaβ the Master Stack's Alpha, enable Alpha Clipping in Graph Settings, leave the threshold at 0.5 (the node outputs a binary 0/1 β the ghost logic is a threshold window, so the cutout is decided inside the function). - On the Blackboard add a Float property, reference name
_TransvoxelFadeAware, default 1, exposed β the markerTransvoxelTerrainlooks for.
Alpha clipping makes Shader Graph apply the same cutout to the shadow and depth passes automatically, so shadows dissolve with the surface β exactly like the bundled shader. (HDRP is untested, like the rest of the package; its Position node would need Absolute World space.)
Properties
{
// Marker only β declaring it is what tags the material as fade-aware.
[HideInInspector] _TransvoxelFadeAware("Fade Aware", Float) = 1
}
// Include AFTER URP's Core.hlsl.
#include "Packages/com.reromanlee.transvoxel/Runtime/Resources/TransvoxelDither.hlsl"
// VERTEX β read the fade channel and pass it down as a varying:
// Attributes: float2 fadeData : TEXCOORD1;
output.fade = TransvoxelVertexFade(input.fadeData);
// FRAGMENT β first statement, before any shading work:
TransvoxelDitherClip(input.positionCS, input.positionWS, input.fade);Add the same two calls to every pass that draws the mesh (forward, ShadowCaster,
DepthOnly β copy the pattern from TransvoxelLitDithered.shader), otherwise shadows and
depth keep rendering the un-faded surface. A fragment stage without an SV_POSITION input
can call TransvoxelDitherClipScreenPos(screenPos, positionWS, fade) instead, which derives
the pixel coordinate from a ComputeScreenPos-style raw screen position.
The terrain checks at startup whether its material declares the marker (or
_TransvoxelFade, for shaders that followed the older snippet). If neither exists, all
fading (including cross-fade ghosts and the edge dissolve) is cleanly disabled β chunks
switch instantly, with a console warning telling you how to enable it. So a custom
material without the module keeps working; it just cannot fade.
Every voxel can be made of a different material. Create a palette via Assets βΈ Create βΈ
Transvoxel βΈ Material Palette, add layers (albedo/color texture, tint, smoothness,
normal + ambient-occlusion + height maps with per-layer strengths, per-layer UV scale β
each with a rotatable preview sphere in the Inspector), assign it to
TransvoxelSettings.materialPalette, and build with it:
terrain.Terraform(worldPoint, radius: 5f, strength: 0.9f, build: true, materialId: 2);- The list index is the material id. Layer 0 fills the whole world by default; build
strokes stamp the selected id onto every solid voxel they touch (so the placed blob reads
as one substance), digging never touches ids. Custom painting code can write
terrain.Materialsdirectly and callInvalidateRegionto re-mesh. - Storage is sparse and tiny. Ids live in 16Β³ byte bricks
(
VoxelMaterialLayer, 4 KB per painted brick) next to the 16 KB density bricks a stroke creates anyway β an unpainted world costs zero bytes, and the GPU backend folds both layers into the one resident brick pool (ids packed 4-per-uint). - One material, any number of textures. Layers are texture/parameter sets β not
Materialassets, whose arbitrary shaders could not run on one blended pixel. Each map kind (albedo, normal, occlusion, height) bakes into a singleTexture2DArrayindexed per pixel (a plain GPU copy when the layers share size/format/mips; mixed inputs are resized and stored uncompressed), so the whole landscape still renders with one material and full SRP batching, whatever the palette size. - Detail maps are pay-for-what-you-use. A palette without any normal/occlusion/height
map renders on the exact albedo-only shader variant it always did β the terrain switches
to the full variant (
TRANSVOXEL_PALETTE_MAPS) only when the palette actually contains such a map; layers with empty slots read baked neutral fallbacks. Normal maps need no mesh tangents: the URP path rebuilds the tangent frame per pixel from screen-space derivatives, so it follows the world-XZ UVs on any slope. Occlusion attenuates ambient/indirect light only. Height maps do two jobs: they steer the blend weights so the higher material (rock, cobbles) cuts through the lower one (sand) at boundaries β the palette's Height Blend slider scales that from plain crossfade to a hard cut, live β and they are the field the parallax ray march reads (see Triplanar and parallax below). - Transitions blend per pixel β and the width is live-tunable. Each vertex takes the id
of the solid voxel it hugs; each triangle carries its (up to three) ids plus one-hot corner
weights in the mesh color channel (
MaterialBlendEncoder, 4 bytes per vertex β vertices split only along material boundaries). The shader sharpens the rasterized barycentric weights withpow(w, materialBlendSharpness): 1 blends across the whole boundary cell, 16 is a near-hard cut. Material ids resolve identically across chunk borders, LOD seams and both meshers, so blends never tear at a seam β proven by the watertightness + seam-consistency tests. - Everything else composes. Painting re-meshes through the normal edit-group path, so a
material change cross-fades through the stipple ghosts like any terraform; CPU, GPU and
Hybrid backends produce identical ids (the
TRANSVOXEL_MATERIALSkernel variant adds one float per soup vertex, welded and encoded exactly like the CPU path).
One palette per scene. The palette bindings β texture arrays, per-layer uniforms, blend
sharpness, the triplanar and parallax switches β are global shader state, for the same
reason the fade inputs are: batched render paths bypass per-renderer state, and the SRP
Batcher would lock per-material values. Two TransvoxelTerrain instances therefore share
whichever palette was bound last, and the terrain warns once if it catches two with
different palettes. One terrain per scene is the supported setup.
Like fading, this needs shader support: the _TransvoxelPaletteAware marker property is
what tags a material as palette-aware, and the palette inputs are global uniforms. The
blend itself lives in the reusable module
Runtime/Resources/TransvoxelPalette.hlsl
(URP only). The terrain binds
every map array whenever a palette is active β kinds the palette doesn't use hold tiny
neutral fallbacks β so module users sample unconditionally; only the bundled shader plays
the TRANSVOXEL_PALETTE / TRANSVOXEL_PALETTE_MAPS keyword game to keep map-free
palettes on the cheaper path (custom shaders wanting the same should list both in one
multi_compile set). With a palette assigned but a non-palette-aware material, voxel
materials are cleanly disabled with a console warning. Palette content edits (textures,
tints, sharpness) re-bind live without rebuilding chunks β including flipping between the
albedo-only and detail-map variants; assigning or swapping the palette asset re-meshes the
world once so every vertex carries blend data.
A graph needs three things: the marker, the corner weights interpolated across the triangle, and one sampling node. (This composes freely with the dithering node from Dithered fading β most graphs want both.)
- Blackboard: Float property, reference name
_TransvoxelPaletteAware, default 1, exposed. - Vertex stage β the one-hot corner weights must rasterize into barycentric weights,
so they have to be computed per vertex: add a Custom Interpolator block (Vector2)
to the Vertex context and feed it a Custom Function node β Type File, Source
TransvoxelPalette.hlsl, NameTransvoxelBlendCorner, inputVertexColor(Vector4) β Vertex Color node, outputCornerWeights(Vector2). - Fragment stage β a Custom Function node from the same file:
TransvoxelPaletteAlbedofor albedo/tint palettes. Inputs:UV(Vector2 β UV node on UV0),VertexColor(β Vertex Color node),CornerWeights(β Custom Interpolator node). Outputs:Albedoβ Base Color,Smoothnessβ Smoothness.TransvoxelPaletteMapsto add the normal/occlusion/height maps. Extra inputs:PositionWS(β Position node, World),NormalWS(β Normal Vector node, World); extra outputs:Occlusionβ Ambient Occlusion, andNormalβ the Normal block after setting Graph Settings βΈ Fragment Normal Space to World (the module outputs a world-space normal β terrain meshes have no tangents).
Pick the function to match your palette: TransvoxelPaletteMaps always pays the full
12-sample path, there is no automatic variant switching inside a graph.
Two upgrades on the palette asset, both off by default and both keyword-gated, so a palette that uses neither compiles to β and costs β exactly what it did without them.
UV0 is a world-space XZ planar map. That is fine for rolling ground and wrong for anything approaching vertical: on a cliff face the U coordinate barely changes while V does, so the texture smears into vertical streaks. It is the oldest visual limitation in this package, and it lands hardest on the shapes a voxel engine exists for β cliffs, overhangs, cave walls.
Tick Triplanar on the palette and every layer is sampled on all three world planes and blended by the surface normal, so each orientation gets an undistorted mapping. Normals use a whiteout blend and come out in world space, which suits meshes that carry no tangents. Triplanar Sharpness sets how narrow the band is where two planes mix: 1 is broad and slightly soft on 45Β° slopes, higher values tighten it toward a hard switch at the diagonals.
Cost is roughly 3Γ the texture fetches. UVs stay a pure function of world position β never mirrored by the normal's sign β which is what keeps them continuous across chunk borders and LOD seams.
Height maps steer material boundaries (above), but that is a blend effect: on a single material it is mathematically an identity and does nothing at all. Parallax Occlusion is the other thing a height map is for.
With it on, the view ray is marched through the blended heightfield per pixel and the UV is displaced to where the ray actually meets the surface. Brick, cobbles and rock strata read as volume with self-occlusion, at zero geometric cost β nothing is tessellated or displaced, which matters when the GPU is already generating the mesh.
- Height Scale (per layer) is the apparent depth in UV units, so rock can be deeper than sand. Keep it well under the size of one feature in the texture: a value wider than the gaps in the pattern steps right over them and smears instead of deepening.
- Parallax Min/Max Steps β the march adapts between them by viewing angle. Head-on needs few steps; grazing rays travel much further through the field and need many.
- Parallax Distance fades the effect out to nothing by that range, so distant and low-LOD chunks pay nothing for depth too small to see.
Parallax only switches on when some layer actually carries a height map, and only the dominant triplanar plane is marched β marching three heightfields would triple the cost of the most expensive part of the shader for no visible gain.
Two honest limitations. Silhouettes and shadows still follow the mesh, because no geometry moved; that is inherent to the technique. And parallax is most useful with triplanar on, since a displaced UV on an already-smeared cliff mapping only amplifies the smear.
TransvoxelPalette.hlsl exposes the whole thing as TransvoxelPaletteProjected, wired
exactly like TransvoxelPaletteMaps (UV, VertexColor, CornerWeights, PositionWS,
NormalWS in; Albedo, Normal, Occlusion, Smoothness out, Fragment Normal Space set
to World). A graph has no keywords, so it reads the palette's triplanar and parallax
switches as globals and branches on them at runtime β same inputs, same look, one node.
Adjacent chunks may differ by one LOD level (the octree enforces this 2:1 balance). Where a coarse chunk meets finer neighbours, a naΓ―ve mesh leaves cracks. The Transvoxel fix, owned entirely by the coarser chunk:
- its full-resolution transition face sits exactly on the boundary and reproduces the finer neighbour's triangulation bit-for-bit, while
- its half-resolution face vertices are pushed inward (the paper's secondary position shift) to land exactly on the coarse chunk's own shrunk boundary.
Together the three surfaces β fine mesh, transition sheet, coarse mesh β close every seam with no shared data between neighbours. Correctness is proven by a headless watertightness test (every LOD boundary produces 0 unmatched edges).
Everything per-frame is bounded, so the frame rate stays flat at any movement speed β fly, sprint or teleport; the worst case is unbuilt terrain filling in near-first, never a freeze:
- Distance-prioritized builds. Scheduled chunks sit in a priority queue keyed by distance to the viewer; CPU workers (or the GPU pump) always take the nearest one, and the whole queue re-sorts whenever the viewer moves. After a teleport the ground under the player meshes first, the horizon last. Terraform rebuilds jump the queue entirely.
- Async octree selection. Deciding what should exist walks thousands of octree nodes; that walk runs on a worker task, and only the cheap diff against the live scene touches the main thread.
- Time-budgeted uploads. Finished meshes upload under
meshApplyBudgetMsper frame (plus a count cap), so a burst of hundreds of finished chunks spreads across frames. - Pooled chunk views. Chunk GameObjects and their
Meshobjects are recycled, not created/destroyed β high-speed chunk churn costs no instantiation or GC spikes. - Coarse LODs sample only their own lattice points, so looking far into the distance costs far less than full-resolution detail (Concept.txt #5).
- Hole-free swaps. Old chunks stay on screen until their replacements are ready and
the newly selected set has settled (plus
lodSwapLinger). A chunk whose transition mask changed waits (bounded bylodSwapLinger) for the neighbours that caused the change to be on screen before swapping, so LOD-ring shifts don't flash seams in the distance. All chunks touched by one terraform stroke swap in the same frame (an "edit group"), so brushing never flashes a one-frame hole along a chunk border. - Bounded retirement. Obsolete chunks are destroyed under a per-frame cap, so even a far teleport (thousands of chunks replaced at once) never spends a whole frame cleaning up.
- Batching-proof fades. Fade parameters are baked into each mesh (a UV2 channel of start time + ghost flag) and animated by the shader from global time β no per-renderer state, no MaterialPropertyBlocks, no per-chunk materials. This survives every render path (SRP Batcher, URP GPU Resident Drawer included) and costs zero per-frame CPU. Only the debug LOD tint uses a property block.
- Collider bakes run off the main thread (
Physics.BakeMesh), attached when ready.
The terrain measures its own footprint β just this package's work, excluding rendering, materials/textures and PhysX collider cooking β so you can see, right now, what all the live chunks and their computation cost:
TransvoxelResourceStats s = terrain.CollectStats();
Debug.Log($"CPU main {s.MainThreadMsPerFrame:0.00} ms/frame, workers {s.WorkerCpuMsPerSecond:0.0} ms/s, "
+ $"GPU {s.GpuComputeMsPerSecond:0.0} ms/s, RAM {s.RamTotalBytes >> 20} MB, VRAM {s.GpuTotalBytes >> 20} MB");- CPU ms β main-thread cost of the terrain's
Update(smoothed + a peak), and worker ms/second across all background threads (sampling, meshing, GPU-soup welding, blend encoding, octree selection, collider bakes), plus builds/second and average build ms. - GPU ms β compute-kernel ms/second, sampled: a tiny readback brackets each job's dispatches on the GPU timeline. Any single sample is quantized to a frame boundary, but the average converges on the true cost over many jobs (Unity exposes no per-dispatch GPU timer at runtime). 0 on the CPU backend.
- RAM β computed exactly from the structures the package owns: density-cache grids, edit and material bricks, pooled + in-flight meshing buffers, and the CPU copies of the chunk meshes.
- GPU memory β the chunk meshes' vertex/index buffers plus every compute buffer (per-job volume/append sets, the resident brick pool, the lookup tables).
Open Window βΈ Transvoxel βΈ Terrain Stats for a live UI Toolkit readout of all of it (auto-picks the scene terrain; most useful in Play mode). The bundled demo overlay also shows a compact CPU/GPU/RAM/VRAM summary, so the numbers appear in standalone builds too.
EditMode tests live in Editor/ (Window βΈ General βΈ Test Runner βΈ EditMode). They prove the
core invariant β the union of all chunk meshes is a closed, consistently wound 2-manifold β
for single chunks, same-LOD borders, every LOD-transition face, and after a transition-mask
change (the stale-cache regression); plus, for voxel materials: watertightness of encoded
(split) meshes, blend-attribute structure, and material-id agreement at every shared vertex
across chunk borders, LOD seams and both meshers; and the resource-stat memory estimators
against known structure sizes. They also guard the things that are easy to break silently:
that the bundled shader compiles, that it still exposes the LOD-tint and marker properties,
that chunk meshes always carry the fade vertex channel, that the palette's texture-array
bake survives formats a Texture2DArray cannot sample, and that cached transition-face sheets
are reused rather than re-sampled.
PlayMode tests live in Tests/Runtime/ and assert on rendered pixels, because this
package's most expensive bugs were all invisible in code and obvious on screen: that LOD
colorization actually changes the image with a palette assigned, that chunkFadeInSeconds = 0
renders the same solid surface as fading enabled, that triplanar removes vertical streaking on
a vertical face (measured as the collapse of vertical variation, not eyeballed), and that
parallax visibly changes the surface. They skip themselves without a graphics device.
Tests inside a package are only discovered when the consuming project opts in β add this to
your project's Packages/manifest.json:
"testables": [ "com.reromanlee.transvoxel" ]The Interactive Demo sample carries its own tests too, which run once you import it.
Requires the com.unity.test-framework package.
- Unity 6000.0+ (developed and verified on 6000.5). Uses
EntityId(the Unity 6.2+ replacement for instance IDs) in the collider-baking path. - URP (
com.unity.render-pipelines.universal), a package dependency. The bundledTransvoxel/Lit Ditheredshader and everything built on it β stipple fades, voxel materials, triplanar, parallax β are URP-only. The mesher itself is pipeline-agnostic: on Built-in or HDRP the terrain still builds, LODs, collides and terraforms, but falls back to that pipeline's default lit material and says so once in the console. - The Input System package, for the Interactive Demo sample only.
Lengyel, Eric. "Voxel-Based Terrain for Real-Time Virtual Simulations." PhD diss., University of California at Davis, 2010. Data tables Β© 2009 Eric Lengyel, from https://transvoxel.org/.


