Rendering

Frame settings & post-processing

Every renderer.feature_* switch with its verified default, the resolution and upscaler controls, the post-process sub-objects, and what ships in the tree without being wired into the frame.

Shade has one frame, and it is configured by plain fields on the Renderer rather than by composing effects. There is no post-process stack to build and nothing to register: every stage below already exists in the frame and is switched on or off where it stands. Reach the renderer from an application as engine.graphics.renderer, or hold the Renderer you constructed yourself.

const renderer = engine.graphics.renderer;   // null before Engine.start(), null again after stop()

renderer.feature_motion_blur_enabled = true;
renderer.motion_blur.strength = 2.0;

renderer.feature_dof_enabled = true;
renderer.dof.applyPreset("cinematic");
renderer.dof.focusOnNearest();

GraphicsEngine deliberately exposes almost none of this itself - renderer is the escape hatch, and it is null outside a started engine, so check it. See Rendering overview for the facade.

Feature switches

Every one is a boolean on Renderer, readable and writable at any time. Defaults are read from shade/renderer/Renderer.js.

SwitchDefaultWhat it does
feature_shadows_enabledtrueMaster shadow switch. When off the shadow context is disabled and CASTS_SHADOW_BIT is cleared on every light record, so no map is selected or rasterized.
feature_tetrahedron_point_shadowsfalseGenerate point-light shadows with the tetrahedral (4-face) path instead of cube (6-face). Same octahedral atlas encoding either way, so it is safe to flip at runtime; it exists for A/B comparison.
feature_ssao_enabledtrueGTAO - ground-truth ambient occlusion plus bent normals. The result lands in the alpha channel of the G-buffer albedo target, which is why GBufferTextures.albedo carries AO.
feature_ssr_enabledfalseScreen-space reflections in IBL, Brick4 and LPV modes, with the selected mode supplying fallback specular lighting. In Brick4, enabling SSR forces the split indirect-lighting path.
feature_restir_di_enabledfalseReSTIR DI - stochastic screen-bounded direct shadowing of point and spot lights. When on, the deferred pass skips its froxel cluster loop entirely and consumes the ReSTIR-resolved radiance.
feature_taa_enabledtrueThe temporal resolve, and with it the upscale. The setter calls indicate_view_change() on a transition, because the history textures go stale while it is off.
feature_sharpening_enabledtrueFidelityFX RCAS, at a fixed sharpness of 0.8.
feature_bloom_enabledtrueKaris-average downsample chain plus tent upsample. The chain also feeds auto-exposure, so it is built when either this or feature_automatic_exposure_enabled is on.
feature_automatic_exposure_enabledtrueLuminance-histogram eye adaptation. When off, exposure_compensation is the fixed value.
feature_motion_blur_enabledfalseJimenez 2014 reconstruction filter. Configure via renderer.motion_blur.
feature_dof_enabledfalseDepth of field. Configure via renderer.dof / renderer.dof_unreal, select with dof_algorithm. Silently skipped unless camera.isPerspectiveCamera === true - the thin-lens circle of confusion has no meaning under orthographic.
feature_virtual_texturestrueSystem-wide kill switch for virtual texture streaming. When off, feedback and residency maintenance stop; already-resident pages keep rendering. Costs nothing while no stack is registered.
feature_particles_enabledfalseGPU particles. When on, every ParticleEmitter node in the scene is simulated on the GPU each frame and drawn through the AVBOIT transparency pipeline, order-independently against the transparent meshes and the opaque scene. See Particles & VFX.
feature_path_tracing_enabledfalseReplaces the rasterized colour with renderer.path_tracer’s accumulation for the frame. Prototype-grade. Turn TAA off while it is on - two temporal filters fighting over one image.
feature_velocity_debug_viewfalseDebug: draws the per-object velocity buffer instead of the shaded image, before TAA.

Non-boolean knobs on the same object:

PropertyDefaultWhat it does
indirect_lighting_modeShadeIndirectLightingMode.IBLIBL: 0, Brick4: 1, LPV: 2. See Global illumination.
transparency_modeShadeTransparencyMode.AVBOITWhich order-independent transparency technique draws the transparent buckets: MBOIT: 0 or AVBOIT: 1. Both are wired at the one transparency call site and take the same inputs, so it is safe to flip at runtime.
avboitAVBOITSettingsSwitches of the AVBOIT mode, read every frame while it is selected. transmission_tint_from_base_color (default false) filters what a transmissive surface lets through by its base colour, glTF-style.
particle_capacityundefinedMost particles alive at once in a scene - the pool every emitter of that scene draws from. Read once, when the scene’s particle system is created on the first frame feature_particles_enabled is on for it; the pool does not grow afterwards.
fused_indirecttrueIn Brick4 mode, collapses diffuse, specular and resolve into one pass. SSR needs the standalone specular target, so enabling SSR forces the split path.
dof_algorithm"raymarch""raymarch" selects renderer.dof (DepthOfField); "unreal" selects renderer.dof_unreal (DepthOfFieldU). One DoF call site, so exactly one runs.
upscale_typeShadeUpscalerType.TAATAA: 0 or NSS: 1. See The upscaler.
exposure_compensation0.0F-stops, applied on top of auto-exposure and used as the fixed value when it is off. Reads 0 before initialize(); the setter asserts the renderer is initialized.
internal_resolution_scale1Fraction of output resolution the scene is rendered at.
pixel_ratiowindow.devicePixelRatioDevice pixels per CSS pixel.
profile_sessionnullAssign a GPUProfileSession to record GPU timings.

Transparency itself cannot be switched off - that flag is private and always on. What is public is which order-independent technique draws it:

ShadeTransparencyModeWhat it does
MBOIT (0)Moment-based OIT (Peters et al. 2017): four power moments of the transmittance function per pixel, additively blended at full resolution into r32float + rgba32float targets - which is what puts float32-blendable on the device floor. Smooth and cheap, and not depth-complexity invariant: stacked near-opaque layers leak.
AVBOIT (1)The default. Adaptive voxel-based OIT (Drobot, SIGGRAPH 2025): extinction splatted into a 1/8-resolution voxel volume whose depth axis is adaptively packed onto 128 slices, integrated into a 3D transmittance lookup. Depth-complexity and shift invariant, needs no 32-bit float blending, and filters the background per channel for transmissive materials when renderer.avboit.transmission_tint_from_base_color is on.

GPU particles draw as a side channel of the AVBOIT pass - through the same volume and the same accumulators as the transparent meshes, which is what makes them composite correctly against those meshes and against the opaque scene without a depth sort of their own. Under MBOIT they are simulated and not drawn.

Order in the frame

The post chain is fixed. Reading Renderer.render_to_target top to bottom, after the scene is complete and transparency has been composited:

AfterTransparency   extensions run here; HDR, internal resolution
  path tracer override      feature_path_tracing_enabled
  velocity debug view       feature_velocity_debug_view
  TAA  or  NSS              feature_taa_enabled + upscale_type   <-- resolution changes here
  depth of field            feature_dof_enabled + dof_algorithm
  motion blur               feature_motion_blur_enabled
  RCAS sharpen              feature_sharpening_enabled
  bloom + auto-exposure     feature_bloom_enabled / feature_automatic_exposure_enabled
BeforePresent       extensions run here; HDR, output resolution
  tone map                  always
Overlay             extensions run here; display space, drawing onto the canvas

Ambient occlusion, reflections, ReSTIR DI, shadows and the indirect-lighting mode all act earlier, during the deferred resolve, and so are not in this list. The phases named on the left are where render extensions attach.

Resolution

MemberTypeNotes
resize(x, y)methodCSS pixels, not device pixels. Asserts non-negative integers. Shade applies pixel_ratio itself.
pixel_rationumberSetter re-derives both resolutions immediately.
output_resolutionVector2Read-only in practice: the getter returns a clone, so writing to it does nothing.
internal_resolution_scalenumberMust be finite and greater than zero. Recomputed lazily at the start of the next frame.
aspect_rationumberWidth over height of the internal resolution. This is what to assign to camera.aspect.

The two derivations, verbatim:

output_resolution   = clamp(ceil(css_size * pixel_ratio),          1,  maxTextureDimension2D)
internal_resolution = clamp(floor(output_resolution * scale),     16,  maxTextureDimension2D)

The floor of 16 px is not arbitrary: depth and G-buffer textures carry 5 mip levels, and allocation fails below 2^4.

Both a resize and an internal-resolution change call indicate_view_change(), which resets TAA, NSS, SSR and path-tracer history - resizing destroys the ping-pong depth textures, including the one holding last frame’s depth, and reprojection would otherwise read zeros. indicate_view_change() is public: call it yourself after teleporting a camera or swapping a scene.

Under Engine, do not call resize directly. engine.graphics.updateSize() passes the viewport size to renderer.resize(), and is already wired to the viewport’s size.onChanged. There is no engine-level pixel ratio: render scale is internal_resolution_scale, which renders the frame smaller and lets TAA upscale it, rather than the browser stretching a smaller canvas.

Dynamic resolution scaling

DynamicResolutionScaling (shade/renderer/DynamicResolutionScaling.js) is not owned by the renderer. It reads and writes internal_resolution_scale through two assignable functions, and is fed one number per frame.

Under Engine it is already built and wired:

const drs = engine.graphics.dynamic_resolution;

drs.enabled = false;        // e.g. for a benchmark that wants a fixed resolution
drs.target_frame_rate = 60; // the engine sets 30

It is a floor-holder, not a frame-rate governor: at the engine’s default target of 30 fps it does nothing at all until a frame costs more than 33 ms, and sits at full resolution on hardware that clears the budget.

Standalone, wire it yourself:

import { DynamicResolutionScaling } from "@woosh/meep-engine/src/shade/renderer/DynamicResolutionScaling.js";

const drs = new DynamicResolutionScaling();

drs.get_scale = () => renderer.internal_resolution_scale;
drs.set_scale = v => { renderer.internal_resolution_scale = v; };

// once per frame
drs.notify_frame(frame_time_seconds);
FieldDefault
enabledtrue
target_frame_time_s1 / 30target_frame_rate is an accessor over this.
min_scale / max_scale0.43 / 1.0Hard bounds on the scale.
tolerance0.10Dead band around the target.
probe_step0.05Step size when probing for a better scale.
min_useful_slope0.005Below this, scaling is not buying frame time and it bails.
settle_frames30Frames to wait after a step before judging it.
bail_lockout_frames600Frames to stay put after a bail.
anomaly_clamp_multiplier6Rejects one-off frame-time spikes.
fast_half_life_frames / slow_half_life_frames8 / 120The two smoothing filters.
warmup_frames30Ignored frames at startup.

Also reset() and notify_frame(frame_time_s).

The upscaler

Both upscalers consume the jittered, internal-resolution scene colour and produce an output-resolution image; they differ only in how the resolve is computed.

upscale_typeClassNotes
ShadeUpscalerType.TAA (default)TAAVariance-clip TAA in YCoCg with a Blackman-Harris resolve and depth-occlusion clip. Constructed with the renderer.
ShadeUpscalerType.NSSNSSNeural Super Sampling, a kernel-prediction network. Reached as renderer.nss.

The upscale is a phase boundary, and it is the one thing about the frame you cannot assume away. AfterTransparency is at internal resolution; BeforePresent and Overlay are at output resolution. The G-buffer stays readable to BeforePresent and is therefore a different size from the colour beside it there. Never write a resolution down as a constant - ask frame.resolution, or frame.describe(handle) for the ratio between two of them.

Two consequences worth stating:

  • With feature_taa_enabled === false there is no upscale at all. The two sides of the boundary are the same size, which is correct only while internal_resolution_scale is 1; with TAA off and the scale below 1, the frame is presented at internal resolution rather than upscaled.
  • The jitter sequence length is derived from the upscale ratio at every resolution change, for whichever upscaler is selected. Nothing to set.

renderer.nss is constructed lazily on first access, which is also when the bundled weights blob decodes - so selecting NSS costs one frame and no setup. Its constructor installs the shipped weights; nss.weights = NSSWeights.from(...) replaces them with trained weights from elsewhere. Tuning fields include jitter_sequence_size, alpha_blend_scale, reset_history and debug_view, plus a dozen network-tuning scalars.

Sub-objects

Seven settings objects hang off the renderer. All are constructed for you; nss and path_tracer are lazy, the rest are built in initialize().

AccessorClassModule under src/shade/renderer/
renderer.motion_blurMotionBlurpostprocess/motion_blur/MotionBlur.js
renderer.dofDepthOfFieldpostprocess/dof/raymarch/DepthOfField.js
renderer.dof_unrealDepthOfFieldUpostprocess/dof/gather/DepthOfFieldU.js
renderer.restir_diReSTIRDIrestir/di/ReSTIRDI.js
renderer.ssrSSRpostprocess/ssr/SSR.js
renderer.nssNSSpostprocess/nss/NSS.js
renderer.path_tracerAccumulatingPathTracerpath_tracer/accumulating/AccumulatingPathTracer.js

Screen-space reflections

SSR traces screen-space depth, resolves against the selected indirect-lighting source, filters spatially and reprojects history. Configure it after the renderer initializes:

import { SSRReprojectionMode }
    from "@woosh/meep-engine/src/shade/renderer/postprocess/ssr/SSRReprojectionMode.js";

renderer.feature_ssr_enabled = true;
renderer.ssr.reprojection_mode = SSRReprojectionMode.Parallax;
renderer.ssr.spatial_denoising_enabled = true;
SettingDefaultEffect
reprojection_modeSSRReprojectionMode.Parallax (0)Parallax-aware history reprojection. Surface (1) uses surface reprojection; CurrentFrame (2) disables temporal history for comparison.
spatial_denoising_enabledtrueEnables the three spatial filtering passes independently of temporal reprojection.
reset_historytrue initiallySet to true after a camera cut or scene change; renderer.indicate_view_change() also resets it.

Changing either mode or spatial filtering resets history. History is also invalidated when the resolution, view or indirect-lighting mode changes, or frames are skipped. A recorded frame becomes reusable history only after successful submission.

MotionBlur

One knob: strength, default 1.0. 1.0 is physically plausible, 2.0-3.0 reads as cinematic.

DepthOfField and DepthOfFieldU

Two implementations behind one interface. DepthOfField is a screen-space ray-march (the Tiny Glade model: aperture sub-rays marched through a half-res depth/CoC buffer, plus tilt-shift); DepthOfFieldU is an Unreal-style Diaphragm/Cinematic gather with a foreground coverage bleed. They share every field below.

FieldDefault
quality"medium""low" / "medium" / "high" / "ultra" - 12/24/32/48 samples, 16/24/32/48 march steps; firefly cleanup on for high and ultra. An unknown value silently falls back to "medium".
presetnullSet through applyPreset(name).
focus{ mode: "auto" }Set through the focus* helpers below.
focus_smoothing0.15
focus_region0.0
f_number2.0
focal_length_mmnullnull derives it from the camera’s field of view.
sensor_height_mm24.0
physical_gain1.0
blade_count00 is a circular aperture.
blade_rotation0.0
highlight_boost1.0

DepthOfField adds march_mode; DepthOfFieldU adds near_blur_gain (default 1.0).

Focus is set through helpers rather than by writing focus directly: focusAuto(), focusAtDistance(d), focusOnWorldPoint(x, y, z), focusOnScreenPoint(x, y), focusOnNearest(), focusOnObject(node).

applyPreset(name) takes one of subtle, portrait, cinematic, miniature, dreamy (exported as DOF_PRESETS / DOF_PRESET_NAMES from postprocess/dof/DOF_PRESETS.js).

ReSTIRDI

FieldDefault
m_initial8Initial candidate samples per pixel.
use_screen_space_shadowtrue
denoise_enabledtrueEdge-aware a-trous denoiser on the resolved signal.
denoise_iterations3More iterations, wider filter, more cost. 0 is equivalent to denoise_enabled = false.

AccumulatingPathTracer

FieldDefault
render_tile_size256One tile per call; the tracer accumulates across frames itself.
min_accumulation_alpha0.01
clear_historyfalseSet by indicate_view_change(); the tracer also resets whenever the camera moves.

Tone mapping and exposure

Tone mapping is the last pass before the canvas and there is no operator to choose. Which of the two shaders runs is decided by the display, through matchMedia("(dynamic-range: high)"), and re-decided whenever that query changes.

SDR pathHDR path
Shadershader_tonemap_LDRshader_tonemap_HDR
CurveACESGT7, at an assumed peak of 1000 nits and 100 nits paper white
Canvas formatnavigator.gpu.getPreferredCanvasFormat()rgba16float, context tone-mapping mode "extended"
Output spacesRGBDisplay-P3
Dither8-bit triangle noise, to kill bandingnone - the space is wide enough

Peak brightness is assumed rather than queried (1000 nits on an HDR display, 80 on SDR) because the browser does not report it, and it is not settable.

Exposure is a single scalar multiplied into the colour just before the curve:

  • With feature_automatic_exposure_enabled on (the default), it comes from a luminance histogram built over the bloom chain’s downsampled image and adapted over time.
  • With it off, the value is 2 ^ exposure_compensation - f-stops, default 0.0 - so a bare feature_automatic_exposure_enabled = false leaves exposure at a fixed 1x. Anything comparing two renders wants adaptation off and this pinned, because eye adaptation keeps drifting for seconds after a change.

exposure_compensation asserts that the renderer has been initialized, so set it after initialize() / Engine.start().

Frame counter and profiling

renderer.frame_count is a read-only counter, incremented after each submitted frame. It indexes the temporal history and drives the TAA/NSS jitter sequence. renderer.onFrameFinished is a Signal carrying the same number.

renderer.profile_session is null and the renderer never makes one - the profiler is a leaf nothing else imports, so an application that does not mention it does not carry it. Assigning a session and the null checks around it are the whole integration:

import { GPUProfileSession } from "@woosh/meep-engine/src/shade/device/timing/profile/GPUProfileSession.js";
import { GPUProfileLevel } from "@woosh/meep-engine/src/shade/device/timing/profile/GPUProfileLevel.js";

const session = new GPUProfileSession({ level: GPUProfileLevel.TIMING });

renderer.profile_session = session;
session.start();
// ... frames ...
const bytes = session.stop();
renderer.profile_session = null;

GPU timings need the optional timestamp-query adapter feature. Shade takes it when offered and never requires it, so on a browser that withholds it the timers go quiet rather than the renderer refusing to start.

Present in the tree, absent from the frame

Several directories under src/shade/renderer/postprocess/ ship and are reachable by import, but nothing in the frame calls them. They are not features, and a switch for them does not exist.

DirectoryContentsStatus
fsr/NOTES.md onlyNo code at all.
fxaa/shader_fxaa_311.jsZero importers.
lens/Chromatic-aberration chunks (chunk_ffx_lens_*, chunk_sample_with_chromatic_aberration)Import each other and nothing else.
perspective/shader_perfect_perspective.js, NOTES.mdZero importers.
sscs/NOTES.md only (screen-space contact shadows)No code at all.
upscale-dither/UpscaleDither.js + its shaderImport each other and nothing else.
vrs/vrs_constants.js, NOTES.mdReached only from shadow/ray/chunk_vrs.js to shadow/ray/graph_vrs_rtx.js, which nothing imports. The ray-traced shadow prototype beside it (shadow/ray/RTXShadows.js) has zero importers too.

Also unused: postprocess/velocity/graph_add_camera_velocity.js - only the per-object velocity variant is called, and that one runs every frame regardless of any switch.

See also