Procedural Generation#
onejs-unity/proc generates noise, geometry and textures at runtime, from JavaScript.
import { noise, mesh, texture } from "onejs-unity/proc"Each of the three has a portable CPU implementation, and noise and textures also have a GPU path. Import a submodule directly if you only need one:
import { noise } from "onejs-unity/proc/noise"Noise#
Four kinds, in 2D and 3D. Every generator takes an options object and returns something you sample.
const perlin = noise.perlin2D({ seed: 42 })
const value = perlin.sample(x, y) // -1 to 1noise.perlin2D(), perlin3D() | Gradient noise. The usual default |
noise.simplex2D(), simplex3D() | Gradient noise with fewer directional artifacts |
noise.value2D(), value3D() | Cheaper, blockier |
noise.worley2D(), worley3D() | Cellular. Scales, cracks, stone, water caustics |
Pass a seed. Without one you get a different world every run, which makes a bug you saw once impossible to see again. With one, the same seed is the same terrain forever, on every machine.
Fractal noise#
One octave of noise is smooth and characterless. Real terrain wants several layers at doubling frequencies, which is what fbm does:
const terrain = noise.perlin2D({ seed: 12345 }).fbm({ octaves: 6 })
const height = terrain.sample(x * 0.1, z * 0.1)More octaves means more fine detail and proportionally more time, since each one is another sample. Six is a lot; four is usually enough for something seen at a distance.
The 3D generators take a third coordinate, which is what you animate to get noise that evolves rather than scrolls:
const fire = noise.simplex3D({ seed: 7 })
const n = fire.sample(x, y, time)Filling arrays#
noise.fill2D() and fill3D() write a whole grid in one pass, which is much faster than a loop of sample calls when you want a heightmap or a lookup table.
Geometry#
const sphere = mesh.sphere({ radius: 1 })
sphere.instantiate("MySphere").setPosition(0, 2, 0)Primitives: cube, sphere, cylinder, cone, plane, torus, quad. mesh.combine() merges several into one, which is worth doing when a scene has many static pieces that never move independently.
For anything the primitives do not cover, build it vertex by vertex:
const pyramid = mesh.builder()
.vertex(0, 1, 0).uv(0.5, 1)
.vertex(-1, 0, -1).uv(0, 0)
.vertex(1, 0, -1).uv(1, 0)
.triangle(0, 1, 2)
.build()Materials#
const mat = mesh.material().setColor("#ff5500")
sphere.instantiate("RedSphere").setMaterial(mat)A material can be shared across instances, and usually should be: one material used by fifty objects is one draw setup rather than fifty.
Displacing a mesh#
Reading the vertex data out, changing it and putting it back is how noise becomes terrain:
const terrain = mesh.plane({ width: 20, height: 20, segmentsX: 64, segmentsZ: 64 })
const data = terrain.getData()
const heightNoise = noise.perlin2D({ seed: 12345 }).fbm({ octaves: 6 })
for (let i = 0; i < data.vertices.length; i += 3) {
const x = data.vertices[i]
const z = data.vertices[i + 2]
data.vertices[i + 1] = heightNoise.sample(x * 0.1, z * 0.1) * 5
}
terrain.setData(data)
terrain.recalculateNormals()
terrain.instantiate("Terrain")recalculateNormals() is not optional after moving vertices. Without it the lighting still describes the flat plane you started from, and the result looks like a photograph of terrain rather than terrain.
Textures#
const marble = texture.marble({ width: 512, height: 512, frequency: 5, turbulence: 3 })Patterns: noise, voronoi, marble, wood, checkerboard, gradient. texture.colorMaps holds ready-made colour mappings, which saves writing the same terrain or heat palette in every project.
Generating a 512 by 512 texture on the CPU is real work. Do it once at load rather than per frame, and reach for the GPU path if it has to change.
The GPU path#
Noise and textures can run as compute shaders instead:
if (noise.gpu.available) {
await noise.gpu.perlin(renderTexture, { frequency: 4 })
}Check available rather than assuming. Compute shaders do not exist on WebGL, so the GPU path is unavailable there and the CPU functions are the whole story. Guarding is not defensive style, it is the difference between a game that works in a browser and one that throws.
| Situation | Use |
|---|---|
| Small grids, under about 64 by 64 | CPU. Dispatch overhead costs more than the work |
| Large textures, 512 and up | GPU |
| Regenerating every frame | GPU, with dispatchSync |
| WebGL | CPU, since there is no alternative |
| Generated once at load | Either |
For animation, preload the shaders at startup and dispatch synchronously in the loop, which avoids allocating per frame:
await noise.gpu.preload() // once, at startup
// then, per frame
noise.gpu.dispatchSync(noiseTexture, "perlin", { time: t })Showing a generated texture#
Put it in backgroundImage. There is no component for this: the style property is the supported path, and it takes a RenderTexture from the GPU path or a Texture2D from the CPU one.
import { useComputeTexture, useAnimationFrame } from "onejs-unity/gpu"
function AnimatedBackground() {
const tex = useComputeTexture({ width: 512, height: 512 })
const time = useRef(0)
useEffect(() => { noise.gpu.preload() }, [])
useAnimationFrame((dt) => {
if (!tex) return
time.current += dt
noise.gpu.dispatchSync(tex, "fbm", { type: "simplex", octaves: 5, time: time.current })
})
return <View style={{ width: "100%", height: "100%", backgroundImage: tex }} />
}The reconciler special-cases backgroundImage so the texture is handed to the C# side once and attached there, rather than being marshalled across on every frame, and it is released when the element goes away. A texture regenerated in place therefore costs nothing extra to display: the element already points at it.
Hooks#
import { useNoise, useMesh, useMaterial, useMeshInstance, useProcCleanup } from "onejs-unity/proc"
function Rock() {
useProcCleanup()
const rockMesh = useMesh({ type: "sphere", radius: 1 })
const mat = useMaterial({ color: "#8899aa" })
useMeshInstance(rockMesh, { name: "Rock", position: { x: 0, y: 2, z: 0 }, material: mat })
return null // the mesh is in the 3D scene, not in your UI
}useNoise and useNoise3D build a noise source, useNoiseTexture a GPU texture, and useMeshFactory hands you the whole mesh namespace. They exist so a generator is created once rather than on every render, which is the mistake this API makes easy: noise.perlin2D() in a component body rebuilds the generator sixty times a second.
Returning null is normal here. These components produce scene objects, not elements.
Clean up after yourself#
This is the part that bites.
Procedural meshes, instances and materials are Unity objects. They live in the scene, not in the React tree, so nothing removes them when your component unmounts. A hot reload during development is an unmount, so a leak here shows up as a scene slowly filling with duplicates of everything you have been working on.
mesh.dispose()
instance.dispose()
material.dispose()
mesh.cleanup() // everything the module has madeuseProcCleanup() does it on unmount, and is the right default in a React component. Reach for the explicit calls when you are generating outside one, or when you want a mesh released before the component goes away.
Notes#
Seeds are the difference between a feature and a bug. Anything you might want to reproduce (a daily challenge, a replay, a screenshot somebody reported) needs a seed you control and can log.
Frequency is where the look lives. Most disappointing noise is not the wrong algorithm, it is the right one sampled at the wrong scale. Multiply your coordinates before sampling and try a decade in each direction before changing anything else.
Prefer simplex over perlin for anything large and organic. Perlin has a mild bias toward the axes that reads as a grid once a landscape is big enough to see it.