Rendering

Sky & environment

Shade has no skybox - one octahedral environment texture lights the scene and paints the background. Covers the three ways to supply it, the Hosek CPU sky recipe, volumetric fog, and the two path tracers.

There is no skybox object in Shade, and no sky component. scene.lights.environment is a single octahedral ShadeTexture that does two jobs at once: it is the image-based-lighting source every surface reads its indirect light from, and it is what the background pass draws behind the geometry. Replace it and both change together. There is deliberately no set_environment_texture counterpart to set_environment_map - the environment already is the visible sky.

One texture, two jobs

Once the G-buffer and ambient occlusion exist and before indirect lighting is added, Renderer records shader_background (src/shade/renderer/deferred/shader_background.js). The pass is depth-tested equal against background depth, so it only touches pixels no geometry covered; for each of those it unprojects through the camera’s inverse view-projection matrix and calls sample_environment_color(tEnvironment, view_direction), which reads mip 0 of the environment texture with bilinear octahedral filtering. The same texture is separately convolved on the GPU into an irradiance map and a set of roughness mips for the lighting path.

Two consequences worth internalising:

  • Whatever you want in the sky has to be in the map. Sun disc, horizon haze, cloud shapes, time of day - there is no analytic layer on top of it.
  • The background is drawn at the map’s base resolution. Both shipped helpers build a 128² octahedral map, which is plenty for an ambient source but visibly soft as a backdrop. If the sky is meant to be looked at, build the map larger.

Two constraints apply to the texture:

ConstraintEnforcement
Must be a ShadeTextureAsserted in the LightCollection.environment setter and again in GraphicsEngine.set_environment_map (texture.isShadeTexture === true)
Must be octahedral-encodedNot checked. A cube or equirectangular texture handed over samples as garbage rather than failing

A scene with no environment renders unlit, because Shade lights with indirect on by default. GraphicsEngine.set_scene(scene) fills that in: when scene.lights.environment === undefined it installs make_default_environment(). A scene that arrives with an environment keeps it.

Getting a texture in

CallImportWhat you get
make_default_environment()src/engine/graphics3/make_default_environment.jsSynchronous. A 128² octahedral f16 procedural sky/ground gradient - blue above, warmer bounce below, brighter at the horizon. This is what set_scene installs when you supply nothing.
load_cube_environment(folder_path, file_extension)src/engine/graphics3/load_cube_environment.jsPromise<ShadeTexture>. Six sRGB face images, decoded to linear and reprojected to a 128² octahedral f16 map on the CPU at load.
load_environment_map(url, projection)src/shade/renderer/light/environment/rgbe/load_environment_map.jsPromise<ShadeTexture>. An RGBE .hdr file. See the download gotcha below.
import { load_cube_environment } from "@woosh/meep-engine/src/engine/graphics3/load_cube_environment.js";

// fetches posx.png, negx.png, posy.png, negy.png, posz.png, negz.png
const texture = await load_cube_environment("/textures/sky/", ".png");

engine.graphics.set_environment_map(texture);

The face names and the extension are concatenated straight onto the folder path, so include the trailing slash and the leading dot.

The load_environment_map download gotcha

ProjectionMappingType is exported from the same file as { Equirectangular: 0, Octahedral: 1 }, and projection defaults to Equirectangular. On that branch the function resamples the panorama to octahedral and then calls download_env_map(...), which hands the converted buffer to downloadAsFile(..., '<name>.hdr', 'binary'). In other words, loading an equirectangular .hdr triggers a file download in the player’s browser, every time, as a side effect. The source carries a TODO make a conversion tool, to avoid having to do this in main thread every time.

Pass a pre-converted octahedral map instead:

import { load_environment_map, ProjectionMappingType }
    from "@woosh/meep-engine/src/shade/renderer/light/environment/rgbe/load_environment_map.js";

engine.graphics.set_environment_map(
    await load_environment_map("/textures/sky_octahedral.hdr", ProjectionMappingType.Octahedral)
);

The Octahedral branch parses the RGBE payload straight to Float16 and skips the resample entirely, which is both correct and much cheaper. The file that one accidental Equirectangular run downloads is the converted map - run the conversion once during authoring, ship the result, and never take the equirectangular path at runtime.

There is no prefilter step

set_environment_map does no prefiltering. Shade convolves the map into its irradiance and roughness mips on the GPU, notices when the texture has been swapped, and redoes that work itself. Assignment is the entire API, and it is safe before startup as well as after:

engine.graphics.set_environment_map(texture);   // also raises graphics.needDraw
scene.lights.environment = texture;             // equivalent, minus the redraw flag

The Hosek CPU sky

The Hosek-Wilkie analytic sky model ships in src/engine/graphics/sh3/sky/hosek/. Nothing turns it into an engine environment for you: getting its output into scene.lights.environment is something you write.

ExportFileSignature
sky_hosek_precomputesky/hosek/sky_hosek_compute_irradiance_by_direction.js(mCoeffsXYZ, mRadXYZ, sun_direction, turbidity, rgbAlbedo, overcast) - fills 27 XYZ coefficients plus a 3-component radiance scale
sky_hosek_compute_irradiance_by_directionsame file(out, mCoeffsXYZ, mRadXYZ, mToSun, dir_x, dir_y, dir_z) - writes linear RGB into out
make_sky_hoseksh3/path_tracer/make_sky_hosek.js(sun = [0,1,0], turbidity = 1, overcast = 0, albedo = [0,0,0]) - precomputes once and returns a closure
render_hosek_sky_to_equirectangularsky/hosek/render_hosek_sky_to_equirectangular.js(sun, width, height, { turbidity = 2, overcast = 0, albedo = [0,0,0], target }) - returns a Float32Array of RGBA

make_sky_hosek is the convenient one. It normalises the sun direction for you, applies a fixed 8e-5 radiance scale, clamps negatives, and returns a sampler with the signature (result, result_offset, direction, direction_offset). Turbidity runs 1 to 10, overcast blends toward a CIE overcast sky, and albedo is linear ground reflectance (not sRGB). All directions are in the engine frame, +Y up.

Recipe: an analytic sky as the environment map

Nothing ships that does this end to end. The chain below is the one make_default_environment and load_cube_environment both follow, with the Hosek sampler substituted for their own source:

sample the sky into a float Sampler2Dsampler2d_to_f16ShadeImage.fromSampler2Dset image.color_space to LinearSRGBShadeTexture.fromgraphics.set_environment_map

Sampling straight into the octahedral grid avoids an intermediate panorama and the orientation problem below:

import { make_sky_hosek }
    from "@woosh/meep-engine/src/engine/graphics/sh3/path_tracer/make_sky_hosek.js";
import { Sampler2D }
    from "@woosh/meep-engine/src/engine/graphics/texture/sampler/Sampler2D.js";
import { sampler2d_to_f16 }
    from "@woosh/meep-engine/src/engine/graphics/texture/sampler/sampler2d_to_f16.js";
import { octahedral_uv_to_direction }
    from "@woosh/meep-engine/src/shade/renderer/light/environment/octahedral_uv_to_direction.js";
import { ColorSpace }
    from "@woosh/meep-engine/src/shade/renderer/texture/ColorSpace.js";
import { ShadeTexture }
    from "@woosh/meep-engine/src/shade/renderer/texture/ShadeTexture.js";
import { ShadeImage }
    from "@woosh/meep-engine/src/shade/renderer/texture/source/ShadeImage.js";

const RESOLUTION = 512;

const sky = make_sky_hosek([0.3, 0.4, 0.1], 3, 0, [0.1, 0.1, 0.1]);

const sampler = Sampler2D.float32(4, RESOLUTION, RESOLUTION);
const direction = [0, 0, 0];

for (let v = 0; v < RESOLUTION; v++) {
    for (let u = 0; u < RESOLUTION; u++) {
        // texel centres, so the mapping stays symmetric across the octahedron's folds
        octahedral_uv_to_direction(direction, (u + 0.5) / RESOLUTION, (v + 0.5) / RESOLUTION);

        const offset = (v * RESOLUTION + u) * 4;

        sky(sampler.data, offset, direction, 0);   // writes linear RGB
        sampler.data[offset + 3] = 1;
    }
}

const image = ShadeImage.fromSampler2D(sampler2d_to_f16(sampler));

// radiance, not colour: these values never had a transfer curve on them.
// LinearSRGB is already ShadeImage's default; both shipped helpers set it explicitly anyway.
image.color_space = ColorSpace.LinearSRGB;

engine.graphics.set_environment_map(ShadeTexture.from(image));

octahedral_uv_to_direction is the CPU port of the exact WGSL decode the GPU samples with, and the source is emphatic that every CPU-side producer of octahedral texels must go through it so the two can never drift apart. Use it rather than rolling your own fold.

If you go through a panorama, watch the orientation

resample_equirectangular_to_octahedral(sampler, output_resolution) is available and takes a float Sampler2D, so the panorama route works - but the two functions do not agree on a convention:

LongitudeLatitude
render_hosek_sky_to_equirectangular writesu = atan2(z, x) / 2pi + 0.5v = asin(y) / pi + 0.5, so row 0 is -Y
resample_equirectangular_to_octahedral readsu = (atan2(x, z) + pi) / 2piv = acos(y) / pi, so v = 0 is the zenith

Feeding one directly into the other flips the sky vertically and mirrors it a quarter turn about Y. The result still looks like a sky, which is what makes it easy to miss - check where the sun lands. render_hosek_sky_to_equirectangular follows the three.js equirectUv layout, which nothing in the engine consumes. Either reconcile the two mappings yourself, or sample into the octahedral grid directly as above.

The analytic atmosphere is not wired up

src/shade/renderer/atmosphere/ holds a full Hillaire-style atmosphere model - GPUSky with its transmittance and multiscatter LUTs, shader_transmittance_lut.js, shader_multiscatter_lut.js, shader_sky_irradiance_lut.js, and the hillaire/ parameter set. src/shade/renderer/view/GPUViewSkyContext.js would drive it.

Neither class is instantiated anywhere in 3.21.0. GPUViewSkyContext.update reads scene.sky, and Scene has no sky field. make_default_environment concedes the point in its own comment: the engine has a full atmosphere model available and deliberately does not use it. Treat this as unfinished work in the tree, not a feature. (The directory is not dead weight, though - chunk_sample_environment_color, which the background pass depends on, lives there too.)

Volumetric fog and participating media

The volumetric half of “atmosphere” is real and wired. A ParticipatingMedia component paired with a Transform64 becomes a box of fog, smoke, dust or cloud that Shade marches through.

import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { ParticipatingMedia }
    from "@woosh/meep-engine/src/engine/graphics3/ParticipatingMedia.js";
import { ParticipatingMediaSystem }
    from "@woosh/meep-engine/src/engine/graphics3/ParticipatingMediaSystem.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { VolumetricsParticleSpec }
    from "@woosh/meep-engine/src/shade/renderer/volumetrics/ParticipatingMediaVolume.js";
import { MIE_PARTICLES_STANDARD_PRECOMPUTED }
    from "@woosh/meep-engine/src/core/math/physics/mie/MIE_PARTICLES_STANDARD_PRECOMPUTED.js";

const engine = await EngineHarness.bootstrap({
    configuration(config, engine) {
        config.addSystem(
            new ParticipatingMediaSystem(engine.graphics, EngineHarness.shadeScene(engine))
        );
    }
});

const medium = new ParticipatingMedia();

medium.particle_spec = VolumetricsParticleSpec.fromMeep(
    MIE_PARTICLES_STANDARD_PRECOMPUTED.FOG_DROPLET_SMALL
);
medium.target_extinction = 0.4;   // per metre, set after the spec
medium.fade_distance = 1;

const transform = new Transform64();

transform.setTranslation(0, 4, 0);
transform.setScale(7, 5, 7);      // the volume is a unit cube, so this is its size in metres
transform.updateMatrix();         // scale reaches the matrix only when it is composed

const ecd = engine.entityManager.dataset;
const entity = ecd.createEntity();

ecd.addComponentToEntity(entity, medium);
ecd.addComponentToEntity(entity, transform);

Set particle_spec before target_extinction: the setter divides by the spec’s extinction to get a density, so assigning the spec afterwards silently changes the strength you asked for.

ParticipatingMediaSystem takes (graphics, scene), declares dependencies = [ParticipatingMedia, Transform64], and calls graphics.set_scene(scene) during startup - it is one of only three systems that do.

The component is the medium; the Transform64 beside it is the box. Shade’s ParticipatingMediaVolume is a unit cube centred on the origin, posed by that transform, so position, size and orientation are already ECS concepts and the component does not restate them. The transform is taken as world space with no hierarchy applied, exactly as lights are: parenting a fog volume to a moving entity leaves the fog where the child’s own transform puts it.

FieldDefaultMeaning
density3e7Particles per cubic metre. Physically a count, so the numbers are large; the default is dense fog. What the useful range is depends entirely on particle_spec.
particle_specVOLUMETRIC_PARTICLE_SPEC_FOGWhat one particle does to light - a Mie solution (extinction, scattering, radius, g). VolumetricsParticleSpec.fromMeep(entry) converts an entry of the generated MIE_PARTICLES_STANDARD_PRECOMPUTED library (src/core/math/physics/mie/), which has named entries for hazes, fogs, clouds and dusts.
fade_distance0.1Metres of soft edge inside the box faces. World space, deliberately not scaled by the transform. Zero gives hard edges, visible as a straight line across the screen wherever the box crosses the view.
target_extinctionderivedExtinction per metre - density expressed in what it does rather than how many particles it took, and comparable across particle specs. Writing it sets density; it is not stored, so changing particle_spec afterwards changes what reading it back gives. Asserts if the spec extinguishes nothing.

The component serialises (ParticipatingMediaSerializationAdapter, typeName = "ParticipatingMedia") and density is what survives a save, not target_extinction.

Two behaviours to plan around:

  • Changes are polled once a frame, not subscribed to. Neither the transform nor a density slider nor a swapped particle spec announces itself to this system, so it compares all three each frame and uploads only when something differs. A change becomes visible within a frame rather than instantly, and a fog volume needs no t64_announce_change to follow its transform.
  • Mutating a ParticipatingMediaVolume directly is invisible. scene.volumetrics is a SceneVolumetrics; add and remove bump its version themselves, but an in-place edit needs scene.volumetrics.invalidate(). Going through the component avoids the question.

There is no feature flag. The renderer builds the volumetrics LUTs whenever the scene holds at least one volume, and composites them over the finished opaque, background and indirect colour in a dedicated pass - before transparency, so transparent surfaces blend over the fog, and before TAA, which then smooths the fog’s per-pixel sampling noise. The fog is already in SceneColor by the time FramePhase.AfterLighting runs, so a render extension at that phase sees it.

Path tracing

Two unrelated path tracers ship, and they are not alternatives to each other.

Shade’s GPU path tracer

renderer.feature_path_tracing_enabled (default false) - one of the frame settings - makes renderer.path_tracer replace the rasterized and shaded colour for the frame. The traced result still goes through TAA and tone mapping like any other colour, which is the problem: the tracer accumulates across frames itself, one tile per call, and resets whenever the camera moves, so turn feature_taa_enabled off while this is on or two temporal filters fight over the same image.

const renderer = engine.graphics.renderer;   // the escape hatch

renderer.feature_taa_enabled = false;
renderer.feature_path_tracing_enabled = true;

renderer.path_tracer.render_tile_size = 256;        // square pixels per call
renderer.path_tracer.min_accumulation_alpha = 0.01; // blend floor

renderer.path_tracer is an AccumulatingPathTracer, constructed lazily on first access. Each render() traces one tile and blends it into the history with weight max(1 / sample_count, min_accumulation_alpha).

The source is direct about what this is for: it binds the GPU TLAS buffer directly, which made it the check that the TLAS builds and traces correctly and the first place a regression in that tree would show. It calls itself prototype-grade rather than a shipping render mode. Do not build a product on it.

The pure-JS CPU path tracer

src/engine/graphics/sh3/path_tracer/ is a complete Monte-Carlo renderer in plain JavaScript, single-threaded, on the CPU. BufferedGeometryBVH builds from a Shade Geometry carrying a position attribute.

ClassFileRole
PathTracerPathTracer.jspath_trace(out, ray, min_bounce, max_bounce, random, scene) - one path, one output RGB
PathTracedRendererPathTracedRenderer.jsDrives a tracer over a camera and scene. render(target, camera, scene, progress) is a generator that yields once per pixel, so a scheduler can spread it over frames. Writes tone-mapped, sRGB-encoded bytes into a Sampler2D.
PathTracedScenePathTracedScene.jsTwo-level BVH (top-level over mesh AABBs, per-mesh for triangles), lights, background sampler. createMesh(geometry, material, transform), addLight(light), optimize() to rebuild the top level.
PathTracedMeshPathTracedMesh.jsOne mesh instance: geometry, material, transform, per-mesh BVH
BufferedGeometryBVHBufferedGeometryBVH.jsPer-geometry BVH

Russian roulette terminates a path with probability 0.5 after min_bounce bounces, scaling survivors by the reciprocal to keep the estimator unbiased.

The background is a closure on scene.__background_sampler with the same (out, out_offset, direction, direction_offset) shape the Hosek sampler has, so make_sky_hosek(...) drops straight in - as does make_sky_rtiw for the Ray Tracing in One Weekend gradient:

scene.__background_sampler = make_sky_hosek([0.3, 0.9, 0.1], 3, 0, [0.1, 0.1, 0.1]);

populate_path_traced_scene_from_ecd(ecd, scene) exists and walks [ShadedGeometry, Transform64] and [Light, Transform64] (directional and point lights only). It does not work on a Shade-authored scene as it stands, and its own JSDoc says why: a ShadedGeometry placed by shade_node_to_entity_composition holds a MeshletGeometry, whose triangles are quantized inside meshlet pages, and a ShadeMaterial - while createMesh wants a Geometry and the tracer’s own StandardMaterial. geometry_build_from_meshlet_geometry decodes the first; there is no converter yet for the second. Entities built directly with a Geometry and a StandardMaterial go through unchanged.

Nothing in the engine calls the CPU tracer - there is no SH3 probe baking; see global illumination for the indirect-lighting modes. It is a reference renderer for offline tooling. Being single-threaded JavaScript it is orders of magnitude slower than the GPU path, which is why PathTracedRenderer.render is a generator: drive it from a scheduler and spread it over frames rather than blocking on it.