Rendering Pipeline¶
Doriax renders through a flexible pipeline that supports 2D, 3D, UI, and render-to-texture workflows. The same codebase targets OpenGL, OpenGL ES, Metal, Direct3D 11, and WebGPU through a backend abstraction layer.
High-level render flow¶
Each frame, the engine runs the following phases in order:
- Camera update — The active scene camera computes the view matrix and projection matrix.
- Frustum culling — Renderables outside the camera frustum are excluded.
- Opaque pass — Opaque geometry is sorted front-to-back and drawn with depth testing enabled for early-Z efficiency.
- Lighting and shadows — Shadow maps are rendered for each shadow-casting light, then the lighting pass applies directional, point, and spot lights.
- Skybox and IBL — The sky cubemap is drawn (when visible). Environment maps derived from the sky feed image-based lighting (IBL) on meshes that opt in.
- Transparent pass — Objects with blending enabled are sorted back-to-front and drawn after opaque geometry.
- UI pass — UI entities are rendered in screen-space canvas coordinates, on top of the 3D or 2D scene.
- Post-processing — Fog and other post effects are applied (if configured).
Cameras that render to a texture (minimaps, mirrors, portals) run this same flow into their own framebuffer before the main view is drawn.
Cameras¶
Three camera projection modes are available:
| Projection | Use | Object |
|---|---|---|
| Perspective | 3D games | Camera with setType(CameraType::CAMERA_PERSPECTIVE) |
| Orthographic | 2D games, isometric | Camera with setType(CameraType::CAMERA_ORTHO) |
| UI | Screen-space canvas | Camera with setType(CameraType::CAMERA_UI) |
Use Camera::setNearClip() and Camera::setFarClip() to tune depth precision. Set a
tight near/far range — a large ratio (e.g. 0.1 to 100 000) causes depth fighting on
distant surfaces. You can also configure the projection in one call with
setPerspective(yfov, aspect, near, far) or setOrtho(left, right, bottom, top, near, far).
A camera can render to its own texture instead of the screen — see Framebuffers and render-to-texture below.
PBR materials¶
Doriax uses a Physically Based Rendering (PBR) material model with the following texture slots and scalar properties:
| Slot / Property | Controls |
|---|---|
baseColorTexture + baseColorFactor |
Base surface color (albedo) |
alphaMode |
Alpha handling: AUTO, ALPHA_OPAQUE, MASK, or BLEND |
alphaCutoff |
Cutout threshold for MASK materials (default 0.5) |
normalTexture |
Surface micro-detail |
metallicRoughnessTexture + metallicFactor |
Surface reflectivity (0 = dielectric, 1 = metal) |
metallicRoughnessTexture + roughnessFactor |
Highlight spread (0 = mirror-smooth, 1 = fully rough) |
occlusionTexture |
Ambient occlusion darkening in crevices |
emissiveTexture + emissiveFactor |
Self-illuminated areas that ignore lighting |
Metallic and roughness share one texture, following the GLTF convention (roughness in the green channel, metallic in the blue channel).
Alpha is the product of baseColorFactor.a and the base-colour texture's alpha.
ALPHA_OPAQUE forces the result opaque, MASK discards pixels below alphaCutoff, and
BLEND preserves partial alpha for transparent rendering. AUTO keeps the historical
Doriax texture-alpha detection used by editor-created materials. Imported GLTF/GLB
materials preserve their explicit alpha mode.
The MASK test is identical in the lit, shadow/depth, and SSR G-buffer passes. A cutout
therefore casts and contributes to screen-space effects with the same silhouette that
is visible in the colour pass.
Material mat;
mat.baseColorTexture = Texture("textures/rock_albedo.png");
mat.normalTexture = Texture("textures/rock_normal.png");
mat.roughnessFactor = 0.85f;
mat.metallicFactor = 0.0f;
mesh.setMaterial(0, mat); // submesh index, material
Lighting¶
The engine supports three light types plus global illumination:
| Type | Use |
|---|---|
| Directional | Sun / moon — affects the entire scene |
| Point | Omni-directional bulbs, torches |
| Spot | Flashlights, stage lighting, headlights |
Scene ambient light (Scene::setGlobalIllumination) fills shadowed areas with a flat
tint. It is separate from IBL — ambient light affects all lit meshes uniformly, while IBL
adds directional reflections and diffuse fill derived from the sky environment.
Light sun(&scene);
sun.setType(LightType::DIRECTIONAL);
sun.setDirection(Vector3(-0.5f, -1.0f, -0.5f));
sun.setColor(Vector3(1.0f, 0.95f, 0.8f));
sun.setIntensity(3.0f);
sun.setShadows(true);
Shadow maps¶
Each shadow-casting light renders a depth map. Engine limits (adjustable at build time):
| Constant | Default | Controls |
|---|---|---|
MAX_LIGHTS |
6 | Max simultaneous lights |
MAX_SHADOWSMAP |
6 | 2D shadow maps (directional/spot) |
MAX_SHADOWSCUBEMAP |
1 | Cube shadow maps (point lights) |
MAX_SHADOWCASCADES |
4 | Cascades for directional CSM |
Shadow edge smoothness is a per-scene setting, Scene::setShadowQuality, with
Percentage Closer Filtering (PCF) kernels of NONE (1 tap, hard edges), LOW
(3x3, default), MEDIUM (5x5), or HIGH (7x7). The kernel size is uniform-driven, so
changing it applies instantly without shader rebuilds:
2D lighting and shadows¶
2D scenes use a dedicated forward light path instead of the PBR model: each
Light2D contributes
color · intensity · falloff(distance) and all contributions add on top of the scene's
2D ambient light (Scene::setAmbientLight2D). The result multiplies the sprite's
base color, so the unlit fast path is preserved — a 2D-lit sprite costs far less than a
PBR-lit mesh. Both paths can coexist: a mesh lit by 3D lights in the same scene simply
adds the 2D contribution on top.
A light's optional height places it on a virtual Z above the 2D plane, giving normal
maps a direction to respond to. Sprites, tilemaps, and mesh polygons generate tangents
automatically, so assigning a normal texture to their material is enough.
2D lights ignore the scene's lightState flag — that switch controls only the 3D
lighting pass.
1D polar shadow maps¶
Shadows from Occluder2D components use per-light
1D polar shadow maps: all occluder outlines in the scene are merged into one segment
buffer and rendered into a one-pixel-tall atlas row per shadow-casting light, where the
X axis is the angle around the light and the stored value is the distance to the nearest
occluder. Lit fragments compare their own angle and distance against the row, with a PCF
filter along it for soft edges: the light's shadowSoftness sets the penumbra width and
the scene's shadow2DQuality (NONE / LOW / MEDIUM / HIGH — 1 to 13 taps) sets
how smoothly it is sampled. The tap count is uniform-driven, so quality changes apply
instantly without shader rebuilds.
| Constant | Default | Controls |
|---|---|---|
MAX_LIGHTS_2D |
16 | Max simultaneous 2D lights (and shadow atlas rows) |
The shadow pass renders line segments only (no scene geometry), so it stays cheap even
with many occluders; each shadow-enabled light adds one atlas row at the light's
mapResolution width. Each light's shadow attenuates only that light's own
contribution — ambient light is never shadowed. See
2D Graphics — 2D lighting for the usage guide.
Fog¶
Fog adds atmospheric depth. Two modes are available:
| Mode | Effect |
|---|---|
LINEAR |
Fog blends from linearStart to full density at linearEnd |
EXPONENTIAL |
Density increases exponentially with distance |
EXPONENTIALSQUARED |
Steeper exponential falloff |
Fog fog(&scene);
fog.setType(FogType::EXPONENTIAL);
fog.setColor(Vector3(0.7f, 0.7f, 0.8f));
fog.setDensity(0.02f);
// For LINEAR mode use fog.setLinearStartEnd(start, end) instead of density
Skybox¶
Add a Sky entity (Skybox component) and assign either a single cubemap texture or six cube-face textures. The sky is rendered as an infinite background behind opaque geometry.
SkyBox sky(&scene);
sky.setTexture("textures/sky_cubemap.png");
// or per-face:
sky.setTextures("daysky",
"sky/px.png", "sky/nx.png",
"sky/py.png", "sky/ny.png",
"sky/pz.png", "sky/nz.png");
In the editor, the Sky component also exposes Visible. When disabled, the sky is not drawn in the viewport but still generates IBL environment maps for meshes that use them. Use this when you want reflections and indirect lighting from an environment without showing the sky dome itself (for example, an interior level with a hidden outdoor HDR environment).
Image-based lighting (IBL)¶
When a scene contains a Sky entity with a valid cubemap texture, the engine builds two environment maps from that sky:
| Map | Purpose |
|---|---|
| Irradiance | Diffuse ambient fill — soft colour bounced from every direction |
| Prefiltered specular | Glossy reflections — sharper highlights on smooth (low-roughness) surfaces |
These maps follow the glTF-style split-sum approximation used in modern PBR pipelines. Rough surfaces sample blurrier mips; mirror-like surfaces pick up crisp sky detail.
IBL is per mesh. Each mesh has a Receive IBL flag (default false in new scenes).
Only meshes with this enabled combine punctual lights (directional/point/spot) with the
sky environment. Meshes also need Receive Lights enabled and a valid normal (tangent
space for normal maps).
Typical workflow:
- Add a Sky entity and assign a cubemap (HDR or LDR).
- Select meshes that should reflect the environment (metal, glass, wet stone, etc.).
- Enable Receive IBL on those meshes in the Properties window.
- Tune Roughness and Metallic on the material — low roughness makes reflections more obvious.
The material preview sphere in the Properties window updates when Receive IBL is toggled, so you can compare lit-only vs environment-lit looks before play mode.
One sky per scene
The render system uses the first Sky component in the scene for both drawing and IBL generation. Keep a single active sky environment unless you know you are replacing it.
Reflection probes¶
IBL reflects one sky environment everywhere, which reads as "outdoors" on every surface. A Reflection Probe captures the environment at a point in the scene and applies it to meshes inside a box-shaped influence volume — chrome in a garage reflects the garage, not the sky. Probe reflections are box-projected (parallax-corrected): reflection rays are projected onto the influence box, so the reflection stays anchored to the room's walls as objects and the camera move, instead of floating at infinity.
The simplest way to add one is the Reflection Probe entry in the Structure panel's create menu. Size its Box Size to the room or area it represents, and enable Receive IBL (plus Receive Lights) on the meshes inside — probes use the same per-mesh opt-in as sky IBL. A Sky is not required: probes supply the specular (reflection) term on their own, while diffuse ambient still comes from the sky irradiance when one is present.
Static and dynamic probes¶
| Mode | Behaviour |
|---|---|
| Static | Uses an authored cubemap when one is assigned; otherwise captures the scene once at load (or when Refresh Probe is pressed) and keeps that result. |
| Dynamic | Re-captures the scene at runtime according to its update policy. |
An authored cubemap is the best-quality and cheapest option: it is GGX-prefiltered and cached like the sky environment, so rough surfaces get correct blurry reflections. Runtime captures have no prefiltered mip chain — rough surfaces approximate the blur with a small angular filter — so prefer static probes with authored cubemaps for strongly rough materials.
Dynamic probes choose when to re-capture with Update:
| Update | When it captures |
|---|---|
| On Load | Once when the scene starts |
| On Move | Whenever the probe entity moves |
| Interval | Every Update Interval seconds |
| Manual | Only when requested — the Refresh Probe button in the editor, or setting needUpdate = true from code |
Runtime captures share a strict budget of one cubemap face per frame: a full refresh takes six frames, and multiple pending probes take turns. Even several dynamic probes therefore cost a fraction of a mirror's full extra scene render per frame — the trade-off is latency, not throughput.
Influence volume and blending¶
| Property | Purpose |
|---|---|
| Box Size | The influence volume's size, scaled by the entity's world scale. The box is world-axis-aligned and centred on the entity. |
| Box Offset | Moves the influence box in the entity's local space. The cubemap is still captured at the entity origin (shown as a gold marker in the viewport when they differ). |
| Blend Distance | Fade band inside the box edges where the probe blends into the sky IBL, hiding the seam at the volume boundary. Runtime blending is limited to the box's smallest half-extent. |
| Intensity | Multiplier on the probe's reflection contribution. |
| Priority | When influence boxes overlap, the higher-priority probe wins; on a tie, the probe whose centre is nearest the mesh wins. |
The engine picks one probe per mesh (using the mesh's world-bounds centre), so keep volumes room-sized rather than object-sized: a mesh is either inside a probe's box or it falls back to the sky environment.
Capture settings¶
| Property | Purpose |
|---|---|
| Cubemap | (static only) Authored six-face cubemap. Leave empty to capture at load instead. |
| Resolution | Capture cubemap face size (16–1024, default 128). |
| Near / Far | Clip planes of the capture cameras. |
| Include Sky | Whether the sky (and scene background colour) appears in the capture. |
// C++: a dynamic probe covering a 12x6x12 room
ReflectionProbe probe(&scene);
probe.setPosition(0.0f, 2.0f, 0.0f);
probe.setBoxSize(12.0f, 6.0f, 12.0f);
probe.setMode(ReflectionProbeMode::DYNAMIC);
probe.setUpdateMode(ReflectionProbeUpdateMode::MANUAL);
probe.refresh(); // manual re-capture from code
local probe = ReflectionProbe(scene)
probe:setPosition(0, 2, 0)
probe:setBoxSize(12, 6, 12)
probe.mode = ReflectionProbeMode.DYNAMIC
probe.updateMode = ReflectionProbeUpdateMode.MANUAL
probe:refresh() -- manual re-capture from code
With SSR enabled, surfaces lit by a local probe keep their probe reflection and SSR adds on-screen detail on top; only sky-IBL surfaces use SSR's energy-conserving replace path.
Capture scope and limitations
Runtime captures render opaque meshes and (optionally) the sky. Transparent meshes, UI, and particles are skipped. Point and spot shadows are reused in captures, but directional shadow cascades are fitted to the main camera, so their coverage inside a capture can be partial. A probe never appears in its own capture, and captures do not include other probes' reflections (no recursion).
Ambient occlusion (SSAO)¶
Screen-space ambient occlusion darkens creases, corners, and contact areas where ambient light is naturally blocked. Like Godot, Unity, and Unreal, it modulates only the ambient/indirect term (IBL or global illumination) — direct light from your sun, point, and spot lights is left untouched — so it reads as soft contact shading rather than a second shadow.
Each frame the render system runs a small depth pre-pass for the main camera, derives occlusion from a rotated hemisphere kernel, blurs it, and the lit mesh shader multiplies the result into its ambient term.
SSAO is a scene setting:
scene.setSSAOEnabled(true);
scene.setSSAORadius(0.5f); // view-space sampling radius (world units)
scene.setSSAOIntensity(1.0f); // strength (exponent on the occlusion factor)
scene.setSSAOBias(0.025f); // depth bias to avoid self-occlusion acne
In the editor, the same controls live under Scene → Ambient Occlusion (SSAO) in the Properties window, including a Debug View toggle that renders the raw AO buffer so you can tune radius/intensity/bias directly.
| Parameter | Effect |
|---|---|
| Radius | How far samples reach in view space — larger is broader/softer, smaller stays in tight creases |
| Intensity | Darkening strength; raise to make occlusion more pronounced |
| Bias | Pushes samples off the surface to stop flat areas self-occluding (acne) |
Because the effect only touches ambient light, it is most visible with a strong ambient source — raise Global Illumination Intensity or use IBL if SSAO looks too subtle in a scene lit mainly by direct light.
Scope and limitations
SSAO is computed for the main camera; render-to-texture cameras and terrain are currently excluded (terrain would exceed the shader's sampler limit). Normals are reconstructed from depth when SSAO runs alone; when SSR is also enabled SSAO reuses the SSR G-buffer's depth and geometric normals — a single shared geometry pass, and sharper at silhouettes. Enabling SSAO recompiles lit mesh shaders.
Screen-space reflections (SSR)¶
Screen-space reflections add real-time reflections of on-screen geometry — wet floors, polished metal, glossy surfaces — by marching the camera depth buffer in screen space and sampling the lit scene colour where a reflected ray hits. Like Godot and Unity, SSR is energy-conserving: where a ray finds a hit it replaces the surface's IBL environment reflection rather than adding on top of it, and where a ray misses (off-screen or occluded) the IBL reflection remains as the fallback. So SSR refines what IBL already provides instead of double-counting it.
When SSR is enabled the main camera first renders a small G-buffer geometry pre-pass — packed depth, view-space normal, roughness/metallic, and base colour — then runs three fullscreen passes:
- March — reflect the view ray about the G-buffer normal and step it through depth (with a binary-search refine) until it crosses on-screen geometry.
- Glossy blur (optional) — blurs the reflection in proportion to surface roughness, so rough materials get soft reflections while mirrors stay sharp.
- Composite — recomputes the surface's IBL specular and blends the reflection over it with the correct GGX reflectance (including metal tint), writing the final image.
The opaque colour pass is rendered into an offscreen buffer first so the march has a full scene-colour image to sample. SSR therefore requires a framebuffer destination — the editor viewport, a render-to-texture camera, or an engine framebuffer.
SSR is a scene setting:
scene.setSSREnabled(true);
scene.setSSRMaxDistance(8.0f); // max ray length in view-space units
scene.setSSRThickness(0.5f); // depth-compare tolerance (view-space units)
scene.setSSRMaxSteps(48); // linear march sample count (quality vs cost)
scene.setSSRIntensity(1.0f); // reflection strength multiplier
scene.setSSRBlur(0.0f); // glossy blur amount [0..1] (0 = sharp/mirror)
scene.ssrEnabled = true
scene.ssrMaxDistance = 8.0
scene.ssrThickness = 0.5
scene.ssrMaxSteps = 48
scene.ssrIntensity = 1.0
scene.ssrBlur = 0.0
In the editor the same controls live under Scene → Screen-Space Reflections (SSR) in the Properties window, plus a Debug View dropdown for tuning.
| Parameter | Effect |
|---|---|
| Max Distance | How far a reflection ray travels in view space before giving up |
| Thickness | Depth tolerance for accepting a hit — smaller is stricter; larger fills gaps but can smear at contacts |
| Max Steps | March sample count — higher gives sharper/longer reflections at more cost; raise it if reflections miss thin contacts |
| Intensity | Overall reflection strength |
| Glossy Blur | 0 = mirror; raise to blur reflections by surface roughness |
Debug View renders one G-buffer channel full-screen: Reflection (the raw reflection
buffer), Normal, Roughness, Metallic, Albedo, or IBL Specular (the recomputed
environment term SSR blends against — compare it with the in-scene reflections to confirm
the energy match). Off shows the normal render. Per-pixel roughness, metallic, and base
colour come from the material factors and the metallic-roughness / base-colour textures.
Scope and limitations
SSR is screen-space, so it only reflects what is currently on screen: reflections fade out near the screen edges and cannot show off-screen or occluded geometry (IBL fills those in). Thin contact lines where an object meets a reflective surface can leave a small seam — raise Max Steps or lower Thickness to tighten it. SSR runs for the main camera only and is skipped when there is no framebuffer destination.
Framebuffers and render-to-texture¶
A camera can capture its output to a texture instead of the screen — for minimaps,
portals, mirrors, security monitors, or dynamic UI previews. Enable render-to-texture
on the camera and pass its framebuffer wherever a texture is accepted (Mesh, Image,
Polygon, and others have a setTexture(Framebuffer*) overload):
Camera minimapCam(&scene);
minimapCam.setRenderToTexture(true);
minimapCam.setFramebufferSize(512, 512);
// Use the camera's output as a texture on a UI image
Image preview(&uiScene);
preview.setTexture(minimapCam.getFramebuffer());
In the editor, any Texture field can use a camera as its source instead of an image file: click the camera button on the field (or drag a camera entity from the Structure panel onto it). The chosen camera is switched to render-to-texture and its output feeds the slot — the basis for the manual mirror setup below. A camera used this way cannot also be the scene's main camera.
A related mechanism lets the main camera itself render offscreen at a fixed internal resolution that is then upscaled to the window — for pixel-art rendering or GPU performance scaling. That is a scene setting rather than a camera one; see Multiple Resolutions — Fixed resolution.
Mirrors and planar reflections¶
A Mirror turns a flat surface into a true planar reflection — the kind used for mirrors, still water, and polished floors. It is built on render-to-texture: the engine renders the scene a second time from the viewpoint reflected across the mirror plane, then maps that image back onto the surface.
The simplest way to add one is the Mirror entry in the Structure panel's create menu
(or Basic shape → Wall plus a Mirror component). This creates an upright
Wall whose surface normal faces the camera, with a
MirrorComponent already attached. No camera or texture wiring is required — the
component manages its own reflection camera internally.
// C++: a wall mesh that reflects the scene
Shape mirror(&scene);
mirror.createWall(10.0f, 10.0f); // vertical quad, +Z normal (faces the camera)
mirror.setAsMirror(); // one call — engine manages the reflection camera
mirror.setReceiveLights(false); // optional: show the reflection unshaded
Mesh::setAsMirror() works on any flat mesh; pass a normal — setAsMirror(Vector3(0, 1, 0))
— when the surface isn't a Wall (for example a floor created with createPlane). Use
removeMirror() / isMirror() to toggle or query it.
How it works¶
| Stage | What happens |
|---|---|
| Reflected camera | Each frame the active camera is mirrored across the surface plane (entity position + normal). This preserves the handedness flip a real mirror has, so reflected geometry is rendered with reversed face winding to stay front-facing. |
| Projective sampling | The surface samples the reflection texture by screen position, not by mesh UVs, so the reflection stays correctly aligned regardless of the surface's size or placement. |
| Oblique clipping | The reflection camera's near plane is bent onto the mirror plane (Lengyel oblique projection), so geometry behind the mirror cannot leak into the reflection. The sky is excluded from this clip and reflects normally. |
The Normal field¶
MirrorComponent exposes a single Normal — the reflecting surface direction in the
mesh's local space (default +Z, matching a Wall). It is transformed by the entity's
rotation to build the world mirror plane, so rotating the mirror entity orients the
reflection automatically.
If the reflection looks wrong
The reflection image is the same whichever way the normal points, but the behind-the-mirror clipping depends on its sign. If the reflection is clipped on the wrong side (showing geometry that should be hidden, or going mostly empty), flip the sign of the Normal.
Cost¶
A mirror renders the visible scene one additional time per frame into its own off-screen target — the same inherent cost planar reflections have in every engine. Use them deliberately:
- Prefer one hero mirror / water plane over many.
- A mirror never reflects itself, and reflections of other mirrors are not recursive.
- The reflection target matches the canvas resolution by default; lowering it trades sharpness for performance.
Shaders¶
Shaders are authored in GLSL and transpiled by the shader builder for each supported backend. Shader data files are generated at export time.
Each renderable type (Mesh, UI, Points, Lines, Sky) has a built-in shader. In the editor you can fork any of them — per component, or as a scene-wide default for that type — and edit the GLSL; the engine keeps driving the variant system, lighting, and depth/shadow/G-buffer passes. A shader set on the component wins over the scene default, which wins over the built-in. See Custom Shaders.
Built-in skinned variants use the engine's MAX_BONES value consistently in the colour,
depth/shadow, and G-buffer passes; the default capacity is 128 bone matrices. The
editor revisions its compiled shader cache when this built-in shader interface changes,
so an updated editor does not reuse incompatible older skinned variants.
GPU pipeline creation can be deferred by the graphics backend. If a pipeline variant fails creation or validation, Doriax skips that draw instead of submitting its uniform, binding, and draw commands against the failed handle. Diagnose the original pipeline creation message in the Output panel; the dependent validation-error cascade is suppressed.
Supported graphics backends (GraphicBackend enum):
| Backend constant | Target |
|---|---|
GLCORE |
Desktop OpenGL 3.3+ |
GLES3 |
OpenGL ES 3 (Android, WebGL2) |
D3D11 |
Windows Direct3D 11 |
METAL |
macOS and iOS Metal |
WGPU |
WebGPU |
GPU instancing¶
Draw large numbers of identical objects (trees, rocks, enemies) efficiently with mesh instancing. Each instance has its own transform (and optional color and texture region) stored in a GPU buffer, eliminating per-draw CPU overhead:
mesh.createInstancedMesh();
mesh.setMaxInstances(500);
for (int i = 0; i < 500; i++) {
mesh.addInstance(Vector3(i * 2.0f, 0, 0), Quaternion(), Vector3(1, 1, 1));
}
Instances can be modified later with updateInstance(index, ...) and read back with
getInstance(index).
Instancing uses geometry on the same entity as the instanced mesh. Multi-node GLTF models that keep child mesh entities will not draw instances until you merge static model meshes into the root (or build instances on a single-mesh entity / basic shape). In the editor, the Instanced Mesh panel warns when a model still uses the child-mesh layout.
Performance guidelines¶
| Area | Guideline |
|---|---|
| Draw calls | Reduce with instancing, atlases, and shared .material files |
| IBL cost | Environment maps are rebuilt when the sky texture changes; disable Receive IBL on distant or unimportant meshes |
| Shadow casters | Limit shadow-casting lights; cascade only when needed |
| Transparent objects | Keep transparent draw counts low; sort correctly |
| Mobile shaders | Simplify PBR (skip normal maps, lower cascade count) |
| Render targets | Minimize framebuffer resolution for off-screen effects |
| Mirrors | Each mirror re-renders the scene once per frame; keep one hero reflection and lower its target resolution if needed |
| SSR | Adds a G-buffer geometry pass plus fullscreen march/blur/composite passes; lower Max Steps for cost, and it shares its geometry pass with SSAO when both are on |
| Textures | Use compressed formats (ETC2/BC) on mobile/desktop respectively |