Taos Engine · Geo Tutorials

Geo Tutorials · Step 13

Water & Point Clouds

▶ Live demos: Water · Point Cloud — run them from the samples launcher.

Two geo render features that draw over the lit scene rather than feeding the stock geometry pass: water (Cesium-style translucent surfaces from a terrain water mask) and point clouds (.pnts 3D-Tiles content, billboarded with eye-dome lighting). They share one architecture, so they make sense to learn together. Sources: samples/geo_water.ts, samples/geo_point_cloud.ts, src/geo/water_feature.ts, src/geo/point_cloud_feature.ts.

The shared pattern: a render feature over the HDR#

Everything in tutorials 1–4 went through GeoFeature, which appends tile draws to frame.opaque so the engine's deferred geometry pass renders them. Water and point clouds can't work that way — translucent water needs the lit scene behind it to refract, and points aren't triangles. So each is its own RenderFeature with its own WGSL that:

Because they chain frame.hdr, both register before the tonemap feature:

TypeScript
import { TonemapFeature } from 'taos/engine/index.js';

engine.addFeatureBefore(waterFeature, TonemapFeature.name);
engine.addFeatureBefore(pointCloudFeature, TonemapFeature.name);

That's the whole structural idea. The rest is per-feature.


Part A — Water#

Quantized-mesh terrain (Cesium World Terrain, MapTiler quantized mesh) ships a per-tile water mask marking which texels of each tile are water. GeoScene turns those into water-surface draws and exposes them as geo.waterDraws — you just hand that list to a GeoWaterFeature:

TypeScript
import { GeoWaterFeature } from 'taos/geo/index.js';

geo.addTerrain(await resolveIonAsset(ION_ASSETS.worldTerrain, ION_TOKEN),
  { imagery: ESRI_WORLD_IMAGERY });

engine.addFeatureBefore(new GeoWaterFeature({
  normalTexture: rippleNormalTexture,        // a tileable normal map for the surface chop
  drawItems: () => geo.waterDraws as GeoWaterDraw[],
  sun: () => ({ direction: sunDir, color: sunColor, intensity: 3 }),
  settings: waterSettings,                    // live look controls (below)
}), TonemapFeature.name);

It re-draws each water tile's terrain-conformed surface after lighting and samples the mask texture per-fragment, so the coastline is texel-sharp (not a blocky tessellated edge). It reads a snapshot of the lit HDR for refraction and the depth buffer for occlusion plus a soft shoreline fade.

Water needs a water mask, so it needs quantized-mesh terrain. The keyless raster-DEM source (AWS_TERRARIUM) carries no mask — use Cesium ion or MapTiler terrain (tutorial 11). geo_water.ts falls back to a bundled ion token so it runs out of the box.

GeoWaterSettings is read live every frame, so a UI can drive it:

TypeScript
const waterSettings: GeoWaterSettings = {
  opacity: 0.78,                 // base opacity over deep water
  tint: [0.015, 0.08, 0.11],     // underwater color
  sky: [0.30, 0.45, 0.62],       // grazing-angle (Fresnel) reflection color
  normalTileM: 90,               // meters one ripple-normal tile spans
  distortion: 0.012,             // screen-space refraction wobble
  shoreFade: 6,                  // meters of depth over which it fades in at the shore
  fresnelPower: 5,
  specPower: 200,
  normalScale: 0.6,
  debug: false,                  // flat-blue "show me the mask" view
};

Flip debug: true to render the mask flat-opaque blue — the quickest way to see exactly which texels the terrain considers water.


Part B — Point clouds#

.pnts is the 3D-Tiles point-cloud content type (LiDAR scans, photogrammetry points). A point tile has no mesh, so GeoFeature can't draw it — instead each tile's baked GPU point buffer surfaces on content.points, and you feed those to a GeoPointCloudFeature, which expands every point into a billboard quad:

TypeScript
import { GeoPointCloudFeature, type GeoPointCloudDraw } from 'taos/geo/index.js';

const pointFeature = new GeoPointCloudFeature({ drawItems: () => livePointDraws });
pointFeature.pointSize = 3;      // billboard diameter in px
pointFeature.attenuate = true;   // near points grow, far points shrink (reads with depth)
pointFeature.edl = true;         // eye-dome lighting — darken depth edges (the LiDAR look)
pointFeature.edlStrength = 12;
engine.addFeatureBefore(pointFeature, TonemapFeature.name);

Collect the draws each frame from the rendered 3D-tile contents — a point tile carries content.points; the model matrix is the same origin-shift mesh tiles use:

TypeScript
livePointDraws.length = 0;
for (const content of geo.lastResult.tileContents) {
  if (content.points) {
    const s = geo.frame.worldFromEcefPoint(content.bakeOriginEcef);
    const m = Mat4.identity();
    m.data[12] = s.x; m.data[13] = s.y; m.data[14] = s.z;
    livePointDraws.push({ points: content.points, modelMatrix: m });
  }
}

Real .pnts datasets come from Cesium ion. To stay keyless, the geo_point_cloud.ts sample builds a synthetic cloud the same way a streamed tile would — an interleaved position(float32×3) + color(unorm8×4) buffer in origin-relative world space, wrapped in a PointCloudGpu — and drops it on keyless AWS terrain. The same GeoPointCloudFeature draws either.

Eye-dome lighting#

edl is the feature that makes a flat splatter of points read as 3D: a screen-space pass darkens fragments that sit behind their neighbors in depth, so contours and silhouettes pop. Toggle it in the sample (E) to see the difference — without it the cloud is a hard-to-read confetti; with it, shape emerges.


Putting it together#

Both features stack onto the same scene without conflict — water blends over the lit terrain while a point cloud draws above it — and both honor the scene depth, so they occlude and are occluded correctly. The registration order among the HDR-chaining features (water, point cloud, labels, lens flare, …) only matters where they overlap on screen; all just need to be before tonemap.

Next#