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.h—RenderMaterialDesc, the plain-datasurface description (albedo colour + map, metalness, roughness, normal map, emissive colour + map).
-
RenderSystem::createMaterial(name, desc)— create OR UPDATE the namedbackend 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
roughnesson 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 thatinstance (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
| Field | next (default; HlmsPbs datablock, metallic workflow) | classic (RTSS Cook-Torrance material) |
|---|---|---|
| albedo colour + texture | native (diffuse colour + PBS diffuse map) | diffuse colour + texture unit |
| metalness / roughness | native scalars | native scalars (the Cook-Torrance stage reads them from specular.xy) |
| normal map | native (needs mesh tangents — see below) | native (RTSS normal-map stage; needs mesh tangents — see below) |
| emissive colour | native | self-illumination |
| emissive texture | native | additive self-illumination pass (opaque materials only) |
| alphaTest (cutout) | native Hlms alpha test — the caster shader carries the test + diffuse texture, so cutouts SHADOW as cutouts | pass 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 |
| twoSided | native (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 only —
alphaTestis a hard keep/discard; noalpha 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 theoffline-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):
-
the runtime opt-in (
setImageLighting(true, …)— sticky state like theatmosphere; OFF by default),
-
the
r.iblQualitycvar (off/low/medium/high, default medium; the tiercaps the environment chain's resolution — pure table in
core_util/IblPreset.h, live re-arm liker.shadowQuality), - 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 Vistasweep) 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.iblQualitytier (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 everygenerated 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
iblReflectionscapability 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
.omatwhere 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) — thereflected schema + detached record/round-trip.
-
demo_material(integration, both flavors) — import baseline (notexture),
.omatapplies 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) — theORKIGE_DEMO_MATLOOKSrig 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) — theORKIGE_DEMO_CUTOUTrig (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) — theORKIGE_DEMO_ACCENTSrig (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 × chunksarray ofself-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 tilingTEXCOORD_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 ischunk_quads × chunk_quadsquads →(chunk_quads+1)²verts,chunk_quads²·6indices. 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: keepchunk_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; oneModelComponentover the whole.glbis 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 sharedorkige_pngcodec, and ademo_terrain.omatground 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 thenamed water material (idempotent per name; the normal map resolves across all resource groups).
-
void setWaterTime(name, seconds)— advance the ripple animation. A CHEAPmaterial-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;fresnelPowerscales 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.
fresnelPowersharpens 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 — seeDocs/render-abstraction.md("Future desktop quality knob — water refraction/depth pass"). The reflectedRenderWaterDescfields 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, likeRenderLight).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.maxDecalscvar). 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.decal — place(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'sdecalsPerCellabove 0 (0 disables decals), and every decal-diffuse texture loads as anAutomaticBatchingtexture so they share ONE pool whose master feedsSceneManager:: 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::ManualObjectin the node's local XZ plane) floating just above the surface, rendered with a generatedDecal/<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, sosupports("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≈ thecharacter footprint,
lifetime = 0), placed at the character's feet and moved with it. Because it is an ordinary decal it obeys the samer.maxDecalsbudget.
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.