Inspired by Sebstian Lague’s Procedural Planet, I found that the planet after combination of many layers of noises can be more detailed and able to have more possibilities. To implement the combined job, I created a CombinedSurfaceJob, which is a Burst-compiled IJobFor that procedurally displaces a mesh surface by stacking multiple noise layers and then recomputing normals/tangents per vertex quad. It supports both planes and spheres with correct derivatives for shading.
CombinedSurfaceJob
The mesh is authored and processed in quads of four vertices. Each iteration i of the job reads and writes a single Vertex4, which groups four SingleStream.Stream0 structs — v0, v1, v2, and v3 — representing the position and normal, tangent, color, and texCoord0 of the quad respectively.
The job obtains a typed view over the vertex buffer using meshData.GetVertexData<SingleStream.Stream0>().Reinterpret<Vertex4>(16 * 4). Here, 16 is the size (in bytes) of a single SingleStream.Stream0 entry, written four times per quad, resulting in a 64-byte stride.
Finally, the job is scheduled with ScheduleParallel(meshData.vertexCount / 4, resolution, dependency), so the number of parallel work items equals the total number of quads.
Inputs- Vertices buffer (writable) as NativeArray<Vertex4>.
- Noise layers as NativeArray<NoiseLayerData>, copied from a managed array and automatically disposed after job completion.
- Domain transform (SpaceTRS):
- domainTRS (3×4) — transforms positions into noise space.
- derivativeMatrix (3×3) — used for derivative mapping; the code internally builds its own domainMatrix from domainTRS.
- Switches / Scalars:
- isPlane — selects plane or sphere displacement path.
- minHeight — clamps the final combined noise value.
- elevation — global multiplier for per-layer displacement and derivatives; if set to 0, all derivatives are zeroed, yielding a perfectly flat surface.
For each Vertex4 in NativeArray<Vertex4>, the job creates a Sample4 structure that stores the value and its spatial derivatives (x, y, z) for the four vertices in the quad. This enables derivative-based computations for accurate normals and tangents.
Then, using the NoiseLayerData stored in NativeArray<NoiseLayerData>, the following steps are performed:
- Sample base noise: Identify the noiseType and call the corresponding generator (e.g., Perlin, Simplex, or Voronoi-based job).
- Apply displacement scale & elevation: layerNoise *= layer.noiseSettings.displacement * elevation. If elevation == 0, all derivatives are forced to zero for shading stability, producing flat normals and tangents.
- Compute spatial weight: Each layer’s contribution is modulated by a procedural weight from NoiseLayerData.GetWeight(position). The weight uses Perlin noise on (x, z) scaled by weightFrequency and mixes in a hash seeded by noiseSettings.seed, then remaps the result to [weightMin, weightMax].
- Accumulate: Weighted values and derivatives are summed into the combined result for the quad:
combined.v += layerNoise.v * weight
combined.dx += layerNoise.dx * weight
... // Same for y/z - Global clamp: After all layers are processed, apply a final clamp — combined.v = max(combined.v, minHeight) — ensuring the surface never falls below the minimum height.
The SetPlaneVertices method applies noise-based height displacement to plane vertices and analytically computes tangents and normals.
- Sets each vertex’s Y coordinate from the noise value: map noise.v.x/y/z/w to v0/v1/v2/v3.position.y to form a height map.
- Tangents are derived from the x-direction slope noise.dx: normalize (1,dx,0) to get a tangent pointing along X with handedness -1.
- Normals use both x and z derivatives: normalize (-dx,1,-dz) per vertex to obtain upward-pointing normals corrected by surface slope, avoiding post-processing and yielding correct shading for height-mapped planes.
▼ Click to see the detailed Code
Vertex4 SetPlaneVertices(Vertex4 v, Sample4 noise) {
// Create height map
v.v0.position.y = noise.v.x; // ... same for y/z/w
// Tangents: derive from x-slope
float4 normalizer = math.rsqrt(noise.dx * noise.dx + 1f);
float4 tangentY = noise.dx * normalizer;
v.v0.tangent = float4(normalizer.x, tangentY.x, 0f, -1f); // ... same for y/z/w
// Normals: use both x/z derivatives
normalizer = math.rsqrt(noise.dx * noise.dx + noise.dz * noise.dz + 1f);
float4 normalX = -noise.dx * normalizer;
float4 normalZ = -noise.dz * normalizer;
v.v0.normal = float3(normalX.x, normalizer.x, normalZ.x); // ... same for y/z/w
return v;
}
The SetSphereVertices method applies noise-based radial displacement to sphere vertices and analytically updates tangents and normals from derivatives.
- Shift noise by +1 so 1.0 means no displacement, then normalize derivatives by dividing by the displaced radius to express relative change per unit radius.
- Build a position matrix p (float4x3) from the four vertices for SIMD-friendly ops. If tangents exist, construct t, compute displacement td from derivatives, update and orthonormalize via NormalizeRows(), set handedness to -1.
- For normals, compute position displacement pd, assemble and normalize a normal matrix, transpose to per-vertex normals.
- Finally, scale each vertex position radially by its corresponding noise.v.x/y/z/w, preserving direction while adjusting radius—maintaining smooth, correct shading without expensive post-recalculation.
▼ Click to see the detailed Code
Vertex4 SetSphereVertices(Vertex4 v, Sample4 noise) {
// Shift noise values and normalize derivatives
noise.v += 1f;
noise.dx /= noise.v; // ... same for y/z
// Create float4x3 from vertex positions
float4x3 p = float4x3(
float4(v.v0.position.x, v.v1.position.x, v.v2.position.x, v.v3.position.x), // ... same for y/z
);
// Update tangents if they exist
float3 tc = math.abs(v.v0.tangent.xyz);
if (tc.x + tc.y + tc.z > 0f) {
float4x3 t = float4x3(
float4(v.v0.tangent.x, v.v1.tangent.x, v.v2.tangent.x, v.v3.tangent.x), // ... same for y/z
);
float4 td = t.c0 * noise.dx + t.c1 * noise.dy + t.c2 * noise.dz;
t.c0 += td * p.c0; // ... same for c1/c2
float3x4 tt = math.transpose(t.NormalizeRows());
v.v0.tangent = float4(tt.c0, -1f); // ... same for v1/v2/v3
}
// Compute normals
float4 pd = p.c0 * noise.dx + p.c1 * noise.dy + p.c2 * noise.dz;
float3x4 nt = math.transpose(float4x3(
p.c0 - noise.dx + pd * p.c0, // ... same for c1/c2
).NormalizeRows());
v.v0.normal = nt.c0; // ... same for v1/v2/v3
// Apply radial displacement
v.v0.position *= noise.v.x; // ... same for v1/v2/v3
return v;
}
ScheduleParallel packs all parameters, creates the NativeArray<NoiseLayerData>, and returns a JobHandle. Disposal of the noiseLayers native array is chained to the returned handle. Upstream in ProceduralSurface.GenerateMesh, the caller completes the handle, applies mesh data, and optionally recalculates (or uses the job’s) normals/tangents and generates vertex colors.
The parameter elevation acts as a global gain applied to every noise layer’s displacement, scaling both height and derivative values. Setting elevation to 0 collapses all surface variations—vertices return to the base mesh shape, and normals or tangents flatten accordingly.
Meanwhile, minHeight serves as a post-blend clamp on the final combined noise value. On planes, it limits the minimum height; on spheres, after the base radius shift of +1, it enforces a minimum radial scale—ensuring that the surface never collapses inward. This becomes especially useful when simulating an ocean level or planetary water plane.
Vertex ColorsAlthough not directly part of CombinedSurfaceJob, the ProceduralSurface system can execute a separate ColorJob afterward. This process computes per-vertex height (derived from the normalized radial distance within [-1, 1], using the base radius ± maximum displacement) and slope (the angle between the vertex normal and the world up vector).
A defined set of color rules—based on height, slope, noise, latitude, and blend—is then evaluated to determine each vertex’s final color, which is written to mesh.colors. This clean separation between geometry generation (handled by CombinedSurfaceJob) and appearance classification (handled by ColorJob) enables efficient shading and flexible biome control.
See the Shader section for more details on color blending and material response.