Hello World - Milestone 1

Quadtree upload to GPU via WorkGraph

nown-1max
tiles0max seen0level0 / 0buffer0 / 0fill0.0%

Milestone 1: Quadtree upload to GPU via WorkGraph

This is a first step on the way to a stable, extensible terrain system. (I hope lol)

This demo renders a dynamic LOD "terrain" using @hello-terrain/three. A camera-driven quadtree subdivides the terrain surface, uploads tile data to the GPU each frame, and a TSL position node places every instanced tile vertex in world space, orchestrated by a @hello-terrain/work graph.

Whereto Next? Heightmaps!

The next step will be to allow the user to upload their own heightmapFn, which will be executed on the GPU. This will generate something like a virtual texture map of height values per tile, as well as a normal map. Using the heightmap in the vertex shader, we can deform the vertices to their correct elevation positions, and the normals can be used to shade them correctly.

Figuring out ownership

There's no right way to react-three/fiber, I'm wondering if the graph should have ownership of the Mesh/Material (basically wire things automatically) or if the graph should just expose TSL nodes for users to plug into their materials. It's an open question, I have no idea.

Learning how to use @hello-terrain/work

This helped me discover useful patterns for using Work, such as separating creation and write tasks. You basically have a task that creates long-lived artefacts, that is never (or rarely) updated, and downstream tasks will work against them. I also realized I needed a "run-once" task pattern, as well as a way to make params define-able in module scope but "owned" by each graph.

const myParam = param("someDefaultValue");

myParam.set("blah blah"); // will reactively change any task depending on it, unless the graph the task belongs to has "ownership" of that param

// ...later
myGraph.set(myParam, "anotherValue"); 
// doing a set operation with the param on the graph instead of on the param directly
// will result in the graph creating its own reactive value store, separate from other graph instances

This way, you can define params and consume them in multiple graph instances, which means you don't have to use factory functions all the time.

I think it's pretty cool, anyways.

Setting up the graph

terrainGraph() returns a pre-wired Graph containing all the params and tasks needed for rendering the quadtree tiles. Parameters like rootSize, maxLevel, and innerTileSegments control tile resolution and quadtree depth. The quadtreeUpdate param carries the camera position so the LOD system knows where to refine.

import {
  terrainGraph, // the terrain work-graph
  innerTileSegments, // some params the library exports (should I call these like... innerTileSegmentParams???)
  maxLevel,
  maxNodes,
  // .. yada yada
} from "@hello-terrain/three";

const g = terrainGraph();

Connecting controls to params

Each param is updated via g.set(). When a value changes, only the tasks that depend on it are re-evaluated on the next g.run(). Saving compute, but making tasks easy to write.

useEffect(() => {
  g.set(innerTileSegments, () => controls.innerTileSegments);
}, [controls.innerTileSegments]); // this control object comes from leva! 

useEffect(() => {
  g.set(rootSize, () => controls.rootSize);
}, [controls.rootSize]); // this control object comes from leva! 

The render loop

Inside useFrame, the camera position is pushed into the quadtreeUpdate param and the graph is run. The quadtree refines and leaf tiles are uploaded to GPU storage buffers.

useFrame(async ({ camera }) => {
  g.set(quadtreeUpdate, (prev) => {
    prev.cameraOrigin.x = camera.position.x;
    prev.cameraOrigin.y = camera.position.y;
    prev.cameraOrigin.z = camera.position.z;
    return prev;
  });
  // is it okay to do async work in a render loop? Who knows! I'll find out!
  await g.run();
});

After each run, we read the computed outputs with g.peek() and apply them to the Three.js objects:

useEffect(() => {
  // obviously this is ugly, should probably just be a task, eh?
  return g.on("run:finish", () => {
    const leafSet = g.peek(quadtreeUpdateTask);
    if (leafSet?.count && meshRef.current) {
      // the mesh needs to know how many tiles to instance
      meshRef.current.count = leafSet.count;
    }

    const positionNode = g.peek(positionNodeTask);
    if (materialRef.current && positionNode) {
      // if the positionNode is recreated (because the buffer changed)
      // then we need to rebuild the material shaders entirely by flagging it
      // in the traidional three.js style. 
      materialRef.current.positionNode = positionNode;
      materialRef.current.needsUpdate = true;
    }
  });
}, [g]);

Rendering with TerrainMesh

TerrainMesh extends Three.js InstancedMesh. Each instance is a quadtree leaf tile (One draw call!). TerrainGeometry generates a single tile template (a grid with a skirt ring to hide cracks between LOD levels). The position node computed by the graph reads tile data from a GPU storage buffer and transforms each instance into its correct world-space position.

<terrainMesh // I'm not sure if a special terrainMesh is actually necessary...
  ref={meshRef}
  innerTileSegments={controls.innerTileSegments} // ... but it saves us from having to add the geometry
  maxNodes={controls.maxNodes}
>
  <meshStandardNodeMaterial
    wireframe
    colorNode={Fn(() => {
      // paint each tile a different color
      return u32ToColor(int(instanceIndex));
    })()}
  />
</terrainMesh>

Each tile is colored by its instance index so you can see the quadtree subdivision. Move the camera closer to watch tiles split, and further to watch them merge.

Watch the debug graphs, the work-graph only re-runs the tasks whose inputs actually changed!

okaybye

-- Kenny