Skip to content

Latest commit

Β 

History

79 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Transvoxel

Preview of Transvoxel demo

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.

Transvoxel LODs Transvoxel terraforming

Installation

Unity 6000.0+. URP is a dependency and the Package Manager pulls it in for you.

From a Git URL

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 #tag stays put until you change the tag, which is the point of pinning.

From a release tarball

Every release attaches com.reromanlee.transvoxel-<version>.tgz (about 0.8 MB). No Git needed, and the version is frozen.

  1. Download the .tgz from the release.
  2. Put it inside your project β€” a Packages/ folder next to manifest.json works well β€” so the path stays valid for everyone who clones the repo.
  3. 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.

From a local clone (contributing)

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.

After installing

  • 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).

Separation of concerns

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).

Quick start β€” the Interactive Demo sample

  1. Window β–Έ Package Manager β–Έ Transvoxel β–Έ Samples β–Έ Import the Interactive Demo.
  2. Open TransvoxelDemo.unity from 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~/ into Assets/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 inside Assets/Samples/ are yours, and are overwritten by a re-import.

Using the terrain in your own scene needs none of this; see below.

Using it in your own scene

  1. Add TransvoxelTerrain to a GameObject.
  2. Assign a viewer transform (defaults to Camera.main).
  3. 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 surface

Settings (TransvoxelSettings) apply live

Every 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) or GpuCompute (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.

CPU, GPU and Hybrid meshing backends

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.compute do the heavy lifting:
    1. CSVolume builds the chunk's density grid: procedural noise overridden by the player-edit bricks;
    2. CSRegular runs one thread per cell over Lengyel's tables (uploaded once as buffers β€” they are far too large for HLSL initializers);
    3. CSTransition stitches the LOD seams with transition cells, one thread per face cell. Finished triangles return via AsyncGPUReadback (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.

Dithered fading (stipple cross-fade)

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 toward viewDistance per 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. edgeFadeCurve reshapes 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.

Shader Graph (URP)

Any graph becomes fade-aware with one Custom Function node β€” build it once, save the group as a Sub Graph, reuse it everywhere:

  1. Add a Custom Function node: Type File, Source TransvoxelDither.hlsl (from this package), Name TransvoxelDitherFade.
  2. Give it inputs FadeData (Vector2), PositionWS (Vector3), ScreenPos (Vector4) and one output Alpha (Float), then wire:
    • UV node, channel UV1 β†’ FadeData
    • Position node, Space World β†’ PositionWS
    • Screen Position node, Mode Raw β†’ ScreenPos
  3. 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).
  4. On the Blackboard add a Float property, reference name _TransvoxelFadeAware, default 1, exposed β€” the marker TransvoxelTerrain looks 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.)

Hand-written shaders

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.

Voxel materials

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.Materials directly and call InvalidateRegion to 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 Material assets, whose arbitrary shaders could not run on one blended pixel. Each map kind (albedo, normal, occlusion, height) bakes into a single Texture2DArray indexed 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 with pow(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_MATERIALS kernel 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.

Shader Graph (URP)

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.)

  1. Blackboard: Float property, reference name _TransvoxelPaletteAware, default 1, exposed.
  2. 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, Name TransvoxelBlendCorner, input VertexColor (Vector4) ← Vertex Color node, output CornerWeights (Vector2).
  3. Fragment stage β€” a Custom Function node from the same file:
    • TransvoxelPaletteAlbedo for albedo/tint palettes. Inputs: UV (Vector2 ← UV node on UV0), VertexColor (← Vertex Color node), CornerWeights (← Custom Interpolator node). Outputs: Albedo β†’ Base Color, Smoothness β†’ Smoothness.
    • TransvoxelPaletteMaps to add the normal/occlusion/height maps. Extra inputs: PositionWS (← Position node, World), NormalWS (← Normal Vector node, World); extra outputs: Occlusion β†’ Ambient Occlusion, and Normal β†’ 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.

Triplanar and parallax occlusion

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.

Triplanar

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.

Parallax occlusion

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.

Shader Graph

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.

How the seamless LOD works (the interesting part)

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).

Performance notes

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 meshApplyBudgetMs per frame (plus a count cap), so a burst of hundreds of finished chunks spreads across frames.
  • Pooled chunk views. Chunk GameObjects and their Mesh objects 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 by lodSwapLinger) 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.

Resource stats (debug)

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.

Tests

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.

Requirements

  • 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 bundled Transvoxel/Lit Dithered shader 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.

Reference

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/.

About

🏞️ Clean, modular implementation of Eric Lengyel's Transvoxel algorithm for Unity with seamless LOD triangulation of a density field managed by an octree for large, editable, real-time landscapes.

Topics

Resources

Code of conduct

Stars

3 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages