Explore Davui is an interactive 3D island that runs in the browser. Users can explore 360˚ photos taken on the island by clicking into hotspots.
This is a proof of concept so the photos are relatively low res, but they could easily be replaced by gaussian splats, high res 360 photos, or even 360 video.
Almost none of the scene is modelled, everything except the terrain of the island itself is procedurally generated.
It’s built with React Three Fiber on top of three.js, with custom shaders on the water, the terrain, the rocks, the dock and the pool, and a painterly pass sitting over the whole scene to give it a bit of a lofi style.
Using a height field for easy placements
Everything placed on the island needs to ask the ground how high it is.
The obvious way to answer that is to raycast straight down and let three.js tell you what it hit.
The problem is with all the scattered geometry in the scene it needs hundreds of thousands of candidate positions, so you’re making a coffee while the page loads.
Inverting it fixes the whole thing. Walk the triangles once and rasterize them into a flat 768×768 grid of heights that can be used as a lookup table with virtually zero latency.
Building a jungle from scratch
The jungle is five species – coconut palms, canopy trees, tree ferns, ground ferns and heliconias – and every one of them is assembled from the same three geometry builders.
The first sweeps a tapering tube along a curve.
The second builds a leaf blade, and the third pushes an icosahedron’s vertices in and out at random for the lumpy foliage masses the canopy trees wear.
A species is then just a recipe over those three, so a palm is one leaning trunk plus seven to ten arcing fronds with three coconuts tucked underneath.
A canopy tree is a thicker trunk carrying three to five branches, each one ending in a squashed blob of foliage.
Creating leaves from a function
Palm fronds, fern fronds and banana leaves are all the same function with different numbers, and there are only eight of them to set.
export function blade({
length = 2.4,
width = 0.28,
arch = 0.5,
droop = 1.3,
fold = 0.35,
teeth = 0,
waist = 0.7,
steps = 7,
} = {}) {
- length and width: the size of the blade in world units, before the species scale is applied.
- arch and droop: a rise term played against a fall term, so the leaf lifts away from the crown and then bends back down under its own weight.
- fold: how far the middle column of vertices lifts above the two edge columns, giving the blade a V-section instead of a flat plane.
- teeth: pinches every other station inward, so the outline breaks into something suggesting leaflets rather than a smooth paddle.
- waist: where along its length the blade is widest, which is the entire difference between a palm frond and a heliconia leaf.
- steps: how many stations the blade is built from, and the main cost driver in the jungle at roughly eight leaves a plant.
A palm frond is teeth: 0.38 at the default waist of 0.7, while a heliconia is teeth: 0 at a waist of 0.5, so it stays broad most of its length like a paddle.
None of the eight are set directly either, they’re all drawn from ranges. So a species isn’t really a shape, it’s a table of ranges plus a seed.
A coconut palm asks for a frond between 1.9 and 2.7 units long with an arch between 0.42 and 0.62, and every frond on every palm rolls its own numbers out of those windows!
Why not model the plants?
I built them in code rather than modelling them or buying an asset pack because the placement is derived from the terrain.
Anything hand-placed stops being derived the moment you move a building.
Nudging a cabana two units replants the ground it used to occupy and clears the ground it moves onto, and the footpath, the pool terrace and the tree line all work the same way.
That said, this is a real trade rather than a free win. These plants are built to carry a silhouette at resort scale, and they don’t hold up to being inspected frond by frond.
So the orbit controls stop you at 70 units out, set as a fraction of the resting distance so that moving the resting view doesn’t quietly change how far in a visitor can push.
It also has to be the same island on every reload. Every random number in the build comes from a small seeded generator rather than Math.random, so the whole jungle is reproducible from one integer.
Scattering 1,200 plants to sell the dense jungle
Candidate positions come from a jittered grid rather than from pure random, one per cell at a random spot inside it, because uniform random genuinely does produce dense knots and bald patches.
The rule that decides whether a candidate survives is the one line the whole jungle hangs on.
// same layer keeps full spread apart, different layers
// only have to clear each other's stems
const reach =
other.layer === plant.layer
? (plant.spread + other.spread) * 0.5
: plant.trunk + other.trunk
Enforce spacing globally and you get an orchard, evenly spaced trees with bare ground between them. That’s what a minimum-distance rule always gives you.
Enforce it within a stratum instead and ferns are free to grow underneath palms.
Only a small stem radius is respected across layers, which is enough to stop a fern sprouting out of a tree trunk.
Hitting a density target
Each stratum also needs its own quota, at 8% canopy, 30% mid and 62% floor.
Drawing all five species from one candidate stream lets the big trees saturate their spacing immediately while the floor keeps happily accepting.
The water and the shoreline
The ocean is a simple plane geometry under the hood.
All the surface detail lives in the fragment shader, and the vertex shader just bobs the whole plane up and down in a simple sine wave.
Two simplex noise lookups per pixel give you the foam and the wave lines. Thresholding them hard with step() is what makes it read as cartoon water rather than as fog sitting on the water.
The wave lines are contours of the noise field, isolated by subtracting two smoothsteps a hair apart. The threshold oscillates very slightly, so the lines breathe as if a swell were moving underneath them.
The shoreline foam is actually not part of the water.
The terrain draws its own foam stripe and its own wet sand from a world sea level global, riding the same sine animation the ocean uses for lapping waves. The two then read as one surface meeting rather than as two objects overlapping.
That means everything has to be driven from one source of truth. Water level, wave speed and wave amplitude live in a global store, and the ocean, terrain, rocks, dock and catamaran all read the same three values.
The 360 photo scenes
Eight markers stand around the island, on the cabanas, the pool, the jetty, the reef, the catamaran and three beaches.
Clicking one fades the scene out and drops you inside a 360° photo taken at that spot.
It all happens in one WebGL context, with no second canvas, no router and no reload.
The island scene stays mounted with its visibility toggled off, so coming back out is instant.
The transition, and what it waits on
The transition was the fiddliest thing in the project. “The fade has finished” and “the new content is ready to draw” are two different events, and getting them to sync was a pain.
The curtain ultimately has to wait on both. There’s a 320ms fade, plus another 140ms held fully opaque, because a timer isn’t frame-aligned with a CSS transition and lands a frame early while the curtain is still slightly translucent.
GPU performance optimizations
At some point the scene started making my laptop run hot, so I sat down and audited it properly.
Three of the biggest costs in the frame were coming from just basic R3F defaults.
React Three Fiber resolves dpr to [1, 2], so any Retina display renders at four times the pixels.
requestAnimationFrame runs at the display’s refresh rate, which is 120 Hz on a ProMotion Mac. Easily twice what is needed for a scene like this.
And a bare shadows prop maps to the most expensive filter three.js offers while leaving shadowMap.autoUpdate on, so a sun that never moves was re-rendering an identical shadow map 120 times a second over the entire scene!
What changed
Freezing that shadow map was the single biggest performance gain. At the camera distance the lack of shadow movement for the bobbing boat and swaying trees is really not perceptible.
Capping the device pixel ratio at 1.5 and the frame rate at 60 helped a huge amount too.
