Orkige Help Orkige

Materials — the .omat asset and the render-facade material surface

The engine's material authoring is deliberately SIMPLE and GENERATED (the standing "keep materials simple/generated" rule): one small text asset describes one opaque surface in the metal-rough PBS vocabulary, the facade turns it into a named backend material, and a ModelComponent assigns it by reference. No material graphs, no script formats.

The .omat text asset

A .omat is agent-authorable plain text (the .oshape/.oui family): written over MCP write_project_file, generated by Util/*.py, parsed by the pure, headless core_util/MaterialAsset (unit-tested without a renderer, MaterialAssetTests).

Grammar (v1) — one directive per line, # starts a line comment, every directive optional and at most once:

version 1                       # optional; only version 1 is accepted
albedo 0.8 0.4 0.2 1.0          # base colour, RGBA 0..1 (multiplies the map)
albedoTexture rock_albedo.png   # base-colour map (resource name)
metalness 0.25                  # 0..1 (0 = dielectric, 1 = metal)
roughness 0.6                   # 0..1 (1 = fully rough)
normalTexture rock_normal.png   # tangent-space normal map
emissive 0.9 0.7 0.3            # emitted colour (lighting-independent)
emissiveTexture rock_glow.png   # emissive map (tinted by `emissive`)
alphaTest 0.5                   # cutout threshold 0..1 (0 = off): texels whose
                                # albedo alpha falls below it are DISCARDED -
                                # in the colour pass AND the shadow caster
twoSided 1                      # 1 = draw both faces (foliage/flag quads)

Defaults (everything omitted) are a plain white dielectric: albedo 1 1 1 1, metalness 0, roughness 1, no maps, no emission, no alpha test, single-sided. Texture names resolve through the resource groups (engine media AND project assets/), like sprite textures; .omat files under assets/ are id-tracked like any asset, so the reflected reference survives renames.

Parser honesty: UNLIKE .oshape (which tolerates reserved future keywords), an unknown directive is an error — a typo silently ignored would misrender without a trace. Duplicates, wrong value counts, out-of-range scalars, unsupported versions and empty files all fail with a line-numbered message, and the consumer keeps the mesh's current materials (never a crash, never a half-applied look).

The facade surface (engine_render)

  • engine_render/RenderMaterial.hRenderMaterialDesc, the plain-data

    surface description (albedo colour + map, metalness, roughness, normal map, emissive colour + map).

  • RenderSystem::createMaterial(name, desc) — create OR UPDATE the named

    backend material. Idempotent per name; calling again with new values updates the LIVE material, so everything rendering with it follows — this is the scalar-drive path (e.g. lowering roughness on a ground material at runtime to read wet). Returns false (and logs) when a referenced texture is missing; the material is still created with everything that resolved.

  • MeshInstance::setMaterial(name) — assign to ALL sub-meshes of that

    instance (whole-instance granularity is the v1 boundary; per-instance, so other instances of the same mesh resource are untouched). Returns false and keeps the previous materials when the material does not exist or (next) the mesh cannot host its maps.

Component / editor / MCP surface

ModelComponent grew a reflected material AssetRef (asset-kind material): Inspector (picker filtered to .omat + drag-drop), scene serialization (named field, rename-surviving asset id — no format bump), Lua self.material, and the generic MCP get/set_component verbs, all with zero bespoke code (the ONE property registry). The component reads the asset text through the resource system, parses it, feeds createMaterial("Omat/<asset>", …) — ONE live material per .omat, shared by every component referencing it — and assigns it over the mesh's imported materials. Clearing the reference reloads the mesh, restoring the imported look. In the editor's asset browser a .omat classifies as kind material (palette icon); it is not instantiable on its own.

Flavor capability

Fieldnext (default; HlmsPbs datablock, metallic workflow)classic (RTSS Cook-Torrance material)
albedo colour + texturenative (diffuse colour + PBS diffuse map)diffuse colour + texture unit
metalness / roughnessnative scalarsnative scalars (the Cook-Torrance stage reads them from specular.xy)
normal mapnative (needs mesh tangents — see below)native (RTSS normal-map stage; needs mesh tangents — see below)
emissive colournativeself-illumination
emissive texturenativeadditive self-illumination pass (opaque materials only)
alphaTest (cutout)native Hlms alpha test — the caster shader carries the test + diffuse texture, so cutouts SHADOW as cutoutspass alpha rejection (the RTSS SRS_ALPHA_TEST stage discards in the generated shaders) + a generated <name>/Caster shadow-caster override material re-binding the albedo texture with the same rejection — see below
twoSidednative (CULL_NONE + two-sided lighting: back faces light with the flipped normal)CULL_NONE; back faces light with the FRONT normal (the registered subset)

Both flavors now light .omat content through a metal-rough model: Ogre-Next's HlmsPbs and classic's RTSS SRS_COOK_TORRANCE_LIGHTING stage (SRS_NORMALMAP when a normal map is present). Lit content still is not pixel-parity-gated — the shading models are not bit-identical and the ambient/exposure differs (classic's two-colour hemisphere ambient is averaged flat until the hemisphere-ambient parity step) — but the maps render on both. The demo_material selfcheck runs per flavor and asserts apply + render on each, not image equality.

Tangents: both flavors need mesh tangents to perturb the lit normal from a normal map. Next builds them for every UV-mapped mesh at import (MeshLoaderNext.cpp, on the v1 intermediate) and the Hlms REFUSES a normal-mapping material on a tangent-less mesh. Classic builds them ON DEMAND: MeshInstance::setMaterial builds tangents (Ogre::Mesh:: buildTangentVectors, guarded/logged) only when the material being applied binds a normal map and the mesh has UVs but no tangents yet — so a plain material never pays for tangent generation.

Emissive map (classic): rendered as a second additive pass (lighting off, SBT_ADD, the emissive map modulated by the emissive colour). Surface materials are opaque, so additive-over-scene is safe; a translucent albedo skips the glow pass with one log line. When a map is present the emissive COLOUR is the map's tint only — the surface pass's flat self-illumination is zeroed, matching next's emissive = colour × texture (the material_looks_right probe caught the double application: the whole surface glowed instead of just the map's texels). Without a map, both flavors emit the uniform colour.

Colour space (next): PBS textures load raw (no sRGB flag), matching the backend's gamma-space passthrough pipeline (the classic colour-parity rule from Docs/render-abstraction.md) — the trade-off is documented there. The LIT result is gamma-encoded in-shader (sqrt, the Hlms !hw_gamma_write path — enabled for UNORM targets by the overlay port's pbs-honour-non-srgb-target.patch), so PBS lighting displays correctly on the non-sRGB swapchain; unlit/2D content stays raw passthrough.

Metal-rough on classic (order note): the Cook-Torrance lighting stage reads ROUGHNESS from specular.x and METALNESS from specular.y (the occlusion/roughness/metalness layout). The backend writes them in that order; a swapped write turns every rough dielectric into a mirror-smooth metal (black diffuse plus a pin highlight) — the historical field-cube bug.

Shadows: .omat content participates in dynamic shadows on BOTH flavors (the r.shadowQuality knob + a casting directional light — the full design lives in Docs/render-abstraction.md). Receiver code is injected CENTRALLY: next through HlmsPbs, classic through the RTSS shadow-mapping sub-render-state added once to the generated-material scheme, whose shadow factor feeds the same Cook-Torrance stage — no per-material work. Per-OBJECT participation is ModelComponent's reflected castShadows/receiveShadows (default true): the caster flag rides the mesh instance, the receiver flag swaps the instance onto a no-receive variant of its material/datablock (MeshInstance::setReceiveShadows), so instances sharing one .omat keep independent flags. Water receives (WaterComponent.receiveShadows, per-instance material) but never casts. 2D content (sprites, vector shapes, particles, gui) is out of the shadow pass by construction — its generated materials set receive-shadows false and its objects never cast.

Cutout shadow caster (classic): the scene's derived-caster machinery mutates ONE shared plain-black pass per renderable at render time — state the RTSS-generated caster program (built once) cannot follow. So a cutout material generates a per-material shadow-caster OVERRIDE material (<name>/Caster, bound via Technique::setShadowCasterMaterial, which the backend honours verbatim): unlit, the albedo texture re-bound with the same alpha rejection and culling; the RTSS resolver generates its FFP-emulating shader (texturing + alpha test) and the depth pass discards the cutout texels — a leaf shadows as a leaf. An update that drops the cutout retires the override material again.

Runtime accents — per-instance tint + emissive boost

MeshInstance::setTint(Color) (multiplies the albedo; white = off) and MeshInstance::setEmissiveBoost(Color) (ADDS emitted colour; black = off — the hit-flash accent: a tint can only darken, the boost brightens) are runtime-only, per-instance looks. ModelComponent exposes them as setTint(r,g,b)/setEmissiveBoost(r,g,b) (Lua via self.model, see Docs/lua-api.md) and as the reflected transient tint/emissiveBoost Color properties — live-tunable over the inspector, MCP and the debug protocol, but never serialized: the .omat stays the authored truth.

Transport (both flavors): the per-instance VARIANT machinery — the same mechanism receiveShadows uses. On the first non-neutral accent the instance's current materials/datablocks are cloned once per instance (<name>/Accent.<n>; classic re-registers the clone's RTSS render state via the shared variant recipe, next clones the HlmsPbs datablock); subsequent accent calls only parameter-drive the clone from the ORIGINAL's values (cheap: pass params / const-buffer writes). Neutral values (white tint AND black boost) restore the original assignment EXACTLY and retire the clones (toggle identity, pixel-verified). A later setMaterial/setReceiveShadows resets the accents and the component re-applies them — assign material → shadow flags → accents, the one ordering contract.

Per-renderable custom shader parameters were audited and rejected as the transport: classic's generated RTSS programs expose no per-renderable uniform without authoring a bespoke sub-render-state (a parallel shader mechanism), and Ogre-Next's HlmsPbs reads material const buffers, not renderable custom parameters, without a custom Hlms listener. The variant clone reuses the proven receive-shadows machinery instead — one mechanism, both flavors, at the cost of one extra material per ACCENTED instance (un-accented instances share as before; next's auto-instancing regroups by datablock, so a tinted instance draws separately — expected and honest).

Sprites are NOT this feature: SpriteComponent.tint rides per-vertex colour, so a tinted sprite stays inside its batched run (no material clone, no run demotion — see Docs/performance.md). Mesh accents target 3D ModelComponent content; on next, non-PBS sub-items (unlit/vertex-colour content) are skipped by the accent quietly.

Honest v1 boundaries

  • Opaque or binary CUTOUT onlyalphaTest is a hard keep/discard; no

    alpha BLENDING on scene-content materials.

  • No occlusion/specular/detail maps, no UV transforms, no per-sub-mesh

    assignment.

Image-based lighting (sky-sourced, opt-in)

With engine:setImageLighting(true, intensity) (Lua; facade RenderWorld::setImageLighting) every generated PBS material — .omat surfaces and water — gains cubemap specular reflections plus a cubemap diffuse fill, ADDED on top of the analytic lights and ambient, never replacing them. intensity scales only the added contribution.

One IBL path, TWO sources — the source is selected automatically from the sky the atmosphere shows, and there is NO separate verb to choose it:

  • a skybox atmosphere (AtmosphereDesc::skyboxTexture) feeds the

    offline-baked prefiltered cubemap chain (the original source);

  • a procedural atmosphere feeds a runtime capture of that sky (see

    below) — so a scene lit by the procedural sky gets matching image lighting;

  • a colour sky or a disabled atmosphere still has no meaningful

    environment: enabling under them logs one honest line and renders unchanged.

Both sources hand the SAME cubemap+mip chain to the SAME downstream consumer (next binds it as the HlmsPbs reflection map, classic as the RTSS image-based-lighting stage's cubemap), so everything below the source is one code path.

Three switches gate it, and all three must hold before anything renders (until then the scene pays neither memory nor per-frame cost and stays byte-identical):

  1. the runtime opt-in (setImageLighting(true, …) — sticky state like the

    atmosphere; OFF by default),

  2. the r.iblQuality cvar (off/low/medium/high, default medium; the tier

    caps the environment chain's resolution — pure table in core_util/IblPreset.h, live re-arm like r.shadowQuality),

  3. an enabled atmosphere showing a skybox or a procedural sky.

Source 1 — the baked skybox chain

The cubemap's mip chain IS the roughness chain: Util/make_sky_assets.py bakes a prefiltered chain (box downsample + widening in-face tent blur — an offline cosine-lobe approximation; the top mip stays untouched so the sky picture is unchanged), and both flavors' native samplers index it by surface roughness. A tier cap below the source's edge drops leading mips (a cheap re-upload, no re-filtering).

Source 2 — the captured procedural sky

When the procedural sky is showing with no authored skybox, the environment is synthesized at runtime from the atmosphere parameters + the sun by the pure, headless core_util/SkyEnvMap (unit-tested in SkyEnvMapTests): a small RGBA8 cubemap at the tier resolution, its faces from an analytic zenith→horizon→ground gradient plus a soft glow toward the sun, uploaded as a manual cubemap whose roughness mip chain is a per-face box downsample (the same box-filter prefilter stand-in the offline chain uses). It then binds through the ONE consumer above.

  • The sky model is EXACTLY the one the classic flavor draws its visible

    gradient dome with, so classic reflections match the classic sky by construction.

  • On next the VISIBLE sky is the native AtmosphereNpr dome (a different,

    physically-based model); using this analytic gradient for its reflections is a deliberate tolerance-parity approximation (the dominant cues — sky tint from above, a warm sun reflection toward the sun — are captured), consistent with IBL already being tolerance parity, not per-pixel. It is also an approximation of a ground-truth GGX prefilter (the box-mip blur), as the baked chain is.

  • Design note: a GPU cubemap-capture workspace (rendering the AtmosphereNpr

    sky alone into a cube target) was considered and rejected — it would be next-only (classic would still need CPU synthesis), harder to test, and carries compositor-cubemap lifecycle risk. One pure generator serving both flavors is less total code and more parity.

Capture cadence — on demand, never per frame. A fresh capture happens only when the sky changes materially: the sun rotates past ~6° (a cosine threshold) or a sky colour / power / density moves (SkyEnvMap::CaptureKey

  • materiallyDiffers). A day→night sun arc (the benchmark's Terrace Vista

    sweep) therefore recaptures a handful of times, not every frame; a sub-threshold frame-to-frame nudge reuses the bound chain. The synthesis is a small allocation-plus-upload (six faces at 32/64/256 texels per the tier), so even the recaptures are cheap. Resolution rides the existing r.iblQuality tier (chainResolution) — no capture-resolution knob was added. Classic recapture updates the cubemap contents in place (the RTSS stage keeps its binding by name, like a skybox tier re-arm); next rebinds the fresh texture, so its reflection updates immediately.

Flavor mapping — both native, tolerance parity (not per-pixel):

  • next: the HlmsPbs reflection map (PBSM_REFLECTION) on every

    generated PBS datablock + the scene's envmapScale/diffuse-GI env feature — specular AND diffuse from the one cubemap, additive in the shader.

  • classic: the shader-generator image-based-lighting stage appended to

    the generated Cook-Torrance materials (split-sum DFG lookup table from the shader-library media + the same cubemap; also specular + diffuse, additive). A GLES2/WebGL1 context without GLSL ES 3.0 refuses with one log line — the iblReflections capability bit is runtime-gated there.

  • Scope delta inside the tolerance: next's mesh importer emits PBS

    datablocks, so glTF-embedded materials pick the reflection map up too; classic's imported materials stay outside the generated Cook-Torrance set and render without the added term (author an .omat where the reflection matters on both flavors).

Capability probe: engine:supports("iblReflections") / RenderSystem:: supports(RenderCaps::IblReflections) (the same bit gates BOTH sources — the procedural capture is a new source under the one capability, not a new one). MCP: the cvar rides set_cvar (r.iblQuality), the opt-in is script state (reload_script/scene scripts), the capability shows in get_state. Verified by the render_facade_selfcheck image-lighting leg per flavor: the skybox route (cubemap colour signature on a mirror material, toggle-off pixel identity, tier re-arm) AND the procedural route (a smooth-metal surface brightens with the captured sky, a material sun move triggers exactly one fresh capture while a sub-threshold nudge does not, the refusal under a colour sky), plus the IblPresetTests and SkyEnvMapTests unit suites.

See it: the demo_sky hello_orkige selfcheck cycles the three sky types and toggles IBL on a near-mirror metal cube; the benchmark Terrace Vista vignette runs a skybox sky + IBL over its PBS props end to end (Docs/benchmark.md).

Sample + tests

Util/make_material_demo.py (stdlib-only, committed outputs) generates the hello_orkige demo set: demo_material_cube.glb (UV-mapped cube, imports textureless), demo_mat_albedo/normal/emissive.png, demo_material.omat plus its probe siblings demo_material_flat.omat (same surface, no maps) and demo_material_ground.omat (normal-mapped, emission-free — the shadow receiver). Tests:

  • MaterialAssetTests (unit) — grammar round-trip + malformed honesty.
  • PropertyReflectionTests "ModelComponent … material" cases (unit) — the

    reflected schema + detached record/round-trip.

  • demo_material (integration, both flavors) — import baseline (no

    texture), .omat applies through the reflected reference (albedo map bound), and a hide/show triangle-count probe proves it renders on the flavor under test.

  • material_looks_right (integration PIXEL probe, both flavors —

    tests/integration_driver/run_material_probe_test.py) — the ORKIGE_DEMO_MATLOOKS rig rendered twice (full maps vs the flat sibling): the maps measurably CHANGE the lit hero (normal-mapped lit ≠ flat-lit), the cast shadow composes on the normal-mapped ground receiver, and after lights-out the emissive map glows while everything else reads dark (the flat-emissive double-application guard).

  • material_cutout_right (integration PIXEL probe, both flavors) — the

    ORKIGE_DEMO_CUTOUT rig (cutout leaf quad + a back-facing sibling over a lit ground, casting sun): the hole renders as backdrop, the back face renders at all (two-sided), and the cast shadow carries a LIT hole — the caster is a cutout too (the dark-sample-centroid check: a filled-disc shadow puts the centroid on dark ground).

  • material_accents_right (integration PIXEL probe, both flavors) — the

    ORKIGE_DEMO_ACCENTS rig (two instances of ONE .omat): a tint + emissive boost changes instance A, leaves sibling B byte-untouched (per-instance isolation on a shared material), and clearing restores A exactly (toggle identity).

Terrain — the baked-mesh pipeline

Terrain in v1 is BAKED CONTENT, not an engine feature: no facade growth, no runtime terrain component, mobile-safe by construction. A generator turns a procedural heightfield into a chunked static mesh that renders through the existing ModelComponent + .omat path above — the terrain takes PBS materials, normals and shadows like any imported model. (Vertex-shader terrain — a Terra-style component — is a separate future effort; the baked mesh is the answer at benchmark/one-vista scale, where there is no streaming.)

Util/make_terrain_mesh.py (stdlib-only, deterministic, committed outputs) generates it:

  • Heightfield: a seeded fractal (fBm value noise, integer-hash lattice —

    no float RNG, so a seed reproduces bit for bit), sampled ONCE over the whole terrain with a centre-weighted radial falloff (a self-contained hill/valley patch, not a clipped tile). No external heightmap data — license-clean.

  • Chunking: the grid is split into a chunks × chunks array of

    self-contained mesh CHUNKS, each a glTF mesh (an engine sub-mesh) with its OWN textureless material so the static importer keeps it a separate sub-mesh (it fuses same-material meshes otherwise). Each chunk carries per-vertex NORMALs (central differences on the SHARED global heightfield, so a vertex on a chunk border gets the same normal in both chunks — no seam or crack) and tiling TEXCOORD_0s (a small seamless ground texture repeats across the patch; the UVs let the next backend build tangents so a normal map applies).

  • Vertex budgets (--chunks / --chunk-quads): a chunk is

    chunk_quads × chunk_quads quads → (chunk_quads+1)² verts, chunk_quads²·6 indices. 16-bit indices cap a sub-mesh at 65 535 verts; the generator ENFORCES (chunk_quads+1)² ≤ 65 535 (a hard error otherwise) so every chunk is one 16-bit-indexed draw. Mobile guidance: keep chunk_quads ≤ 64 (4 225 verts / 24 576 indices per chunk — comfortable on Metal/Vulkan) over an 8×8..16×16 chunk grid for a streaming-free vista at a few hundred k triangles. (Per-chunk frustum CULLING is a project-content choice — instantiate each chunk mesh as its own model; one ModelComponent over the whole .glb is one cull unit regardless of sub-mesh count.)

  • Textures + material: a seamless-tiling demo_terrain_albedo.png

    (earthy ground) + demo_terrain_normal.png (tangent-space, derived from the same seamless field), encoded with the shared orkige_png codec, and a demo_terrain.omat ground material (rough dielectric) that ties them together — ONE tiling material applied across every chunk (the splat-baked simplification for v1).

make_terrain_mesh.py --selftest (unit ctest make_terrain_mesh_selftest) builds a terrain in memory and asserts mesh validity (indices in range, unit normals, finite UVs, chunk/sub-mesh/material counts), chunk-border seam matching, determinism (same seed → byte-identical .glb + textures) and the budget cap. The committed demo terrain (defaults: a 3×3 chunk grid of 10×10-quad chunks = 9 sub-meshes, 1089 verts / 1800 triangles over a 48-unit patch) lands in samples/hello_orkige/media/; the demo_terrain integration selfcheck (both flavors) imports it, applies the tiling .omat across every chunk and proves the whole terrain renders with a hide/show triangle probe.

Water — animated surfaces

Water is a SEPARATE material family from the opaque .omat surface, animated per frame. Unlike a static .omat it is not authored as a text asset: the tunables are reflected properties on WaterComponent (engine_gocomponent) that fill a RenderWaterDesc at runtime — deep/shallow colour, opacity, wave scale/speed, fresnel and a tiling normalTexture (an AssetRef defaulting to the engine-generated water normal map). The component owns a facade MeshInstance of the shared engine water PLANE (water_plane.glb, a UNIT grid in the XZ plane scaled to the world size by the reflected sizeX/sizeZ node scale) and drives the ripple through the facade seam.

The facade surface

Two calls on RenderSystem, siblings of createMaterial:

  • bool createWaterMaterial(name, RenderWaterDesc const &) — create/update the

    named water material (idempotent per name; the normal map resolves across all resource groups).

  • void setWaterTime(name, seconds) — advance the ripple animation. A CHEAP

    material-parameter update (scrolls the normal detail) with NO per-vertex CPU work, so a single plane is mobile-safe. A name with no water material is a silent no-op, so a paused editor that never ticks leaves the surface static (the dormancy rule — like ScriptComponent/VectorAnimationComponent).

WaterComponent::onUpdateComponent is the single per-frame site: it accumulates a scroll clock and calls setWaterTime, reached only under a GameObject-ticking runtime (the player). Vertex waves are OUT of v1 — the surface stays flat and the ripple lives entirely in the scrolling normal maps.

Flavor capability

  • next (HlmsPbs, specular-as-fresnel dielectric): TWO detail normal maps

    bound to the same tiling water normal and scrolled in different directions/ speeds (their interference reads as moving water); realistic transparency that preserves the fresnel edge reflection; the deep colour as the water-body albedo (setBackgroundDiffuse) and the shallow colour as a subtle scatter emissive; fresnelPower scales F0 up from water's physical ~0.02.

  • classic (RTSS metal-rough transparent plane): Cook-Torrance lights the

    deep/shallow tint (opacity = alpha) with the intrinsic Fresnel edge, and the one tiling normal map is used TWO ways at once — the RTSS normal-map stage samples it to LIGHT the ripples (a static relief that catches the sun) and it is bound a second time as a scrolling colour shimmer for visible MOTION. fresnelPower sharpens the reflection (lowers the roughness). REGISTERED subset: the lit ripple relief is STATIC — RTSS classic samples the raw texcoord, so a normal map can light OR scroll on one unit, not both; fully animated normal-mapped water (and the two-detail-normal ripple) is next-only.

This is a per-flavor LOOK, not a pixel-parity case (transparent lit content legitimately differs between the flavors).

Honest v1 boundaries (both flavors)

  • No screen-space refraction distortion and **no true depth-graded

    deep→shallow transmission**. Both need a compositor refraction/depth pass (a workspace restructure on next, out of scope for v1 and gated on NOT editing ports/); a future desktop quality knob — see Docs/render-abstraction.md ("Future desktop quality knob — water refraction/depth pass"). The reflected RenderWaterDesc fields absorb it with no shape change.

  • Grazing-incidence streaking at the plane outline. Near the silhouette the

    view compresses the ripple pattern along the grazing direction, so any tiling detail normal map foreshortens into faint streaks parallel to the plane edge. The generator keeps this below ordinary visibility (domain-warped noise, so no lattice-straight features; a wrapped high-pass, so coarse mip levels are near-flat and the tile repeat carries no coherent low-frequency blobs), but the residual is physics: skybox-sourced image lighting (opt-in, above) adds reflection variety that masks some of it, while the stronger masks — screen-space refraction, or vertex waves breaking the silhouette — remain registered absent / out of v1 scope.

Generator + tests

Util/make_water_mesh.py bakes the shared engine water assets with the Python standard library only (deterministic, byte-reproducible, orkige_png codec): the unit water plane .glb (flat +Y, tiling UVs so the next backend builds tangents for the detail normals) and the seamless tiling water_normal.png (central differences on a domain-warped two-field wave height map). The height field is conditioned against banding before differencing — all three counter-measures keep the map seamless:

  • domain warp: value noise has lattice-aligned extrema, which foreshorten

    into ruler-straight bands on a water plane; two seamless warp fields bend the lattice into organic wavefronts.

  • wrapped high-pass: low-frequency ripple energy survives mip filtering per

    tile and repeats as coherent blobs on distant water; subtracting a wrapped box-blurred copy leaves the coarse mip levels near-flat.

  • separable-DC removal: the per-column/per-row mean slope of a lattice

    noise is a coherent tilt that mip filtering PRESERVES while averaging the real ripple away (straight lit bands parallel to the UV axes); subtracting the periodic column/row mean profiles zeroes it at every mip level.

Both are committed under orkige_engine/media/water/ (registered like the engine-default font dir by the player/editor and bundled to exports under Media/water/). --selftest (unit ctest make_water_mesh_selftest) asserts the plane is a flat unit grid with in-range 16-bit indices, the normal map tiles seamlessly and is +Z-dominant, both are deterministic, and the conditioning holds statistically: no coherent per-column/per-row tilt beyond the 8-bit quantisation floor, ripple slope RMS in a healthy band at full resolution, and near-flat after repeated wrapped 2×2 box downsampling (the coarse-mip guarantee). The demo_water integration selfcheck (both flavors) builds a WaterComponent, applies the scrolling material and proves the transparent surface renders AND the ripple clock advances with a hide/show triangle probe. The water_looks_right PIXEL probe (tests/integration_driver/run_water_probe_test.py) captures two frames and asserts the surface is lit, carries a lively specular sun glint (measured contrast ~97 vs ~0.2 for an unlit slab; the threshold also fails a regression to a subdued ~11 response) and scrolls between frames. WaterComponent's reflected schema + a detached round-trip are unit-tested in PropertyReflectionTests.

Decals — projected surface marks

Decals are the third material-adjacent surface feature (alongside .omat surfaces and water): projected marks stamped onto scene geometry — impact marks, paint splats, footprints, and the blob-shadow fallback tier. The tunables are reflected properties on DecalComponent (engine_gocomponent), which owns a facade RenderDecal on a child scene node and projects the mark DOWN the owner's local −Y onto the surface below it (orient the object so its +Y is the surface normal — a ground mark needs no rotation).

The facade surface

RenderWorld gains one factory and a budget knob (siblings of createLight / setShadowQuality):

  • optr<RenderDecal> createDecal() — the mark handle (RAII, like RenderLight).

    attachTo(node), setDiffuseTexture(name), setSize(width, depth, projectionDepth), setOpacity(0..1), setVisible(bool).

  • void setMaxDecals(unsigned) / getMaxDecals() / getVisibleDecalCount()

    the hard cap on concurrently VISIBLE decals (the r.maxDecals cvar). When more decals exist the OLDEST are hidden (their handles still live); a cap of 0 hides every decal, byte-identical to a scene with none (the mobile-budget escape hatch and the toggle-identity gate). See "Budget" below.

DecalComponent reflected fields (the ONE property registry — inspector / serialization / MCP / Lua): texture (an AssetRef, default the engine decal_mark.png), sizeX/sizeZ (footprint world units), projectionDepth (next box depth along −Y; classic ignores it), opacity, lifetime (seconds, 0 = permanent) and fadeDuration. Lua drive via self.decalplace(pos, normal) re-stamps a fresh mark at a world position (teleporting the owner transform, resetting the age) and fade(seconds) starts an immediate fade-out. Lifetime/fade is DORMANT unless a runtime ticks GameObjects (the editor shows a static preview, WYSIWYG — the ScriptComponent/WaterComponent rule); onUpdateComponent ages the mark and ramps the fade through the pure, headless-tested DecalComponent::fadeFactor.

Flavor capability (RenderCaps::ProjectedDecals)

  • next = a TRUE projected Ogre::Decal (HlmsPbs forward-clustered decal).

    The boot raises setForwardClustered's decalsPerCell above 0 (0 disables decals), and every decal-diffuse texture loads as an AutomaticBatching texture so they share ONE pool whose master feeds SceneManager:: setDecalsDiffuse; each decal references a slice. The mark WRAPS over any geometry inside its projection box. supports("projectedDecals") = true.

  • classic = the honest subset — a surface-aligned textured QUAD

    (Ogre::ManualObject in the node's local XZ plane) floating just above the surface, rendered with a generated Decal/<tex> material (unlit, alpha- blended, depth-checked/not-written, two-sided, with a DEPTH BIAS pulling it toward the camera against z-fighting). Simple and mobile-cheap, but FLAT: it does not wrap over uneven geometry. Classic has no deferred/projective decal path, so supports("projectedDecals") = false (the aligned quad is a tolerance, not true projected decals — no deferred/projective system is built for classic by design).

A per-flavor LOOK, not a pixel-parity case.

Opacity / fade delta: setOpacity dims the classic aligned quad's alpha smoothly (per-vertex). The native projected decal has NO per-decal alpha uniform, so next treats opacity as a visibility THRESHOLD (> 0 shows the full-alpha mark, ≤ 0 hides it) — a fade there pops out at its end rather than dimming continuously.

Texture resolution constraint (next): the shared decal-diffuse array pools by resolution, so every decal texture must be DecalComponent::DECAL_TEXTURE_SIZE (256×256) to share the one pool the scene samples; a different-sized texture is rejected with one log line and renders no mark. The engine decal textures and project decal textures are authored at that size.

Budget

r.maxDecals (registered by AppHost, CVAR_PERSIST, default 256) caps the concurrently visible decals — the mobile-budget discipline. The world keeps live decals in creation order; a create or a live cap change re-enforces it, keeping the newest maxDecals visible and hiding the rest (the OLDEST evicted). Lowering the cap live hides the excess; a cap of 0 hides every decal (rendering the scene byte-identically to one with no decals — the getVisibleDecalCount() == 0 toggle-identity gate). The per-component lifetime fade is independent of the cross-decal budget.

Blob shadows (the fallback shadow tier)

A blob shadow is a content-level PRESET over the same DecalComponent, not an automatic system: set texture to the engine decal_blob.png (a soft dark ellipse) and parent the decal object under a character so it stamps a cheap dark oval on the ground. Use it where real shadow maps are OFF or refused — r.shadowQuality = off, or a GLES2/WebGL context whose runtime depth-texture capability gate turned RenderCaps::DynamicShadows false (see Docs/render-abstraction.md). Recipe: a GameObject with a TransformComponent

  • a DecalComponent (texture = "decal_blob.png", sizeX/sizeZ ≈ the

    character footprint, lifetime = 0), placed at the character's feet and moved with it. Because it is an ordinary decal it obeys the same r.maxDecals budget.

See it: the benchmark Cascade vignette rains its bodies onto a lit PBS floor slab carrying a row of decal_blob.png blob-shadow decals — the projected mark on next, the surface-aligned quad on classic (Docs/benchmark.md).

Generator + tests

Util/make_decal_textures.py bakes the engine decal textures (stdlib only, orkige_png, deterministic): decal_mark.png (a soft white round mark, tintable — the neutral default) and decal_blob.png (a soft dark ellipse — the blob shadow), both 256×256. They live under orkige_engine/media/decals/ (registered like the water/font dirs by the player/editor and bundled to exports under Media/decals/). The render_facade_selfcheck decal leg (BOTH flavors) proves a decal visibly marks a lit surface, that opacity fades the mark back to baseline, that the budget hides the oldest past the cap, and that a cap of 0 renders the surface as if there were no decals (baseline 0.341 → dark-blob mark 0.175 → faded/budgeted-off back to 0.341 on next). DecalComponent's reflected schema, a detached property round-trip, and the pure fadeFactor lifetime/fade curve are unit-tested in DecalComponentTests.