diff --git a/.gitignore b/.gitignore index ea695996c..85f8870d1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,21 @@ pubspec_overrides.yaml /pubspec.lock *.iml **/*/*.filamat +!examples/assets/force_core.filamat +!examples/assets/hologram_projector.filamat +!examples/assets/fire_ground.filamat +!examples/assets/wetness.filamat +!examples/assets/crystal_ice.filamat +!examples/assets/snow_accumulation.filamat +!examples/assets/damage_decals.filamat +!examples/assets/portal_rift.filamat +!examples/assets/electricity.filamat +!examples/assets/invisibility_cloak.filamat +!examples/assets/energy_weapon.filamat **/*/*.g.dart **/*.BAK .wrangler node_modules .claude -.tickets \ No newline at end of file +.tickets +examples/dart/headless_runner/output/ diff --git a/docs/game-effects.md b/docs/game-effects.md new file mode 100644 index 000000000..ee49a42c5 --- /dev/null +++ b/docs/game-effects.md @@ -0,0 +1,705 @@ +# Game-Effect Shaders — guide for contributors + +Eighteen example-level Filament effects for common game VFX: **hit flash, +hologram, force field, dissolve/burn, water, smoke, fire, lava, shockwave, +shore waves, wetness, crystal/ice, snow accumulation, damage decals, portal, +electricity, invisibility cloak, and an energy-weapon suite**. Everything lives at +example level — no engine changes, no new public API. This document explains +what each effect is meant to read as, how it is built, how to render and +iterate, and where the interesting improvement opportunities are. + +Original design rationale: [`game_effects_plan.md`](../game_effects_plan.md) +(repo root). Branch: `game-effect-shaders` (off `develop`). + +--- + +## 1. Where things live + +| What | Where | +|---|---| +| Material sources (Filament `.mat` DSL) | `examples/assets/.mat` | +| Compiled materials (committed) | `examples/assets/.filamat` | +| Scene setups (one per effect) | `examples/dart/examples_lib/lib/src/game_effects_.dart` | +| Shared helpers + animators | `examples/dart/examples_lib/lib/src/game_effects_shared.dart` | +| Registry entries (`game_effects_*`) | `examples/dart/examples_lib/lib/src/registry.dart` | +| Headless renderer (stills + video) | `examples/dart/headless_runner/bin/run_example.dart` | +| Material build script | `materials/build.sh` (via `make materials`) | +| Rendered PNGs / MP4s (local, not committed) | `examples/dart/headless_runner/output/` | + +Shared demo assets (FlightHelmet, BusterDrone, IBLs) are in `examples/assets/`. + +## 2. Environment + +- **matc/resgen** come from a local Filament build: + `FILAMENT_PATH="/Volumes/T7 1/projects/filament/out/cmake-release/tools"` + (Ninja layout — binaries at `tools/matc/matc`, `tools/resgen/resgen`; the + volume name contains a space, so quote it). +- This machine's matc has **no WebGPU support** — `make materials` skips the + `_webgpu`/`_web_combined` variants and the webgpu backend in example + `.filamat`s with a warning. Committed webgpu blobs are left untouched. + Rebuilding Filament with `FILAMENT_SUPPORTS_WEBGPU=ON` restores them. +- `dart pub get` is needed **per package** (`examples_lib` and + `headless_runner`) in a fresh checkout/worktree. The first run of an + example also runs the native build hook (minutes); later runs are fast + unless native inputs changed. + +## 3. Render and iterate + +All commands from `examples/dart/headless_runner/`: + +```bash +# one still. By default stills capture the animators' t=0 state; --time +# picks a specific point in the animation (each setup's intended "golden" +# time is noted in §6): +dart run bin/run_example.dart game_effects_water 768 768 --time 1.7 + +# a video (animators drive time; ffmpeg encodes) +dart run bin/run_example.dart game_effects_water 768 768 --video 6 30 + +# static analysis +cd ../examples_lib && dart analyze +``` + +Fast material-only iteration (skip `make materials`, Metal only): + +```bash +"$FILAMENT_PATH/matc/matc" -a metal \ + -o examples/assets/.filamat examples/assets/.mat +``` + +Full multi-backend rebuild: `FILAMENT_PATH=… make materials` from the repo +root. + +### Gotchas learned the hard way + +- **Vertex-stage `worldPosition` must only be displaced additively.** + `material.worldPosition.xyz += offset` works; any form that *replaces* + the position (or cancels it, e.g. `+= target - worldPosition.xyz`) is + silently lost through this matc/Filament/Metal pipeline — the draw + renders nothing (or flickers, depending on what the optimizer keeps). + The smoke billboards therefore keep their CPU-side positions near the + origin and the shader adds the full animated center + camera-facing + corner offsets; the water grid adds its Gerstner displacement the same + way. Custom `variables` (e.g. `surfaceNormal`, `objectPos`) do not have + this problem — only `worldPosition` does. +- **Fragment-stage `getWorldPosition()` is VIEW-relative on this pipeline** + (and `getWorldFromModelMatrix()` returns garbage for `createGeometry` + meshes). Never do world-space math on it. Instead pipe position through + a custom `variables` channel written in the vertex stage: + - created geometry at the identity transform (planes): `material.worldPos.xyz = getPosition().xyz;` + - transformed assets (the helmet): `material.worldPos.xyz = (getWorldFromModelMatrix() * vec4(getPosition().xyz, 1.0)).xyz;` + - when only a fresnel is needed (the shockwave dome), skip position + entirely: transform the normal by `getViewFromWorldMatrix()` in the + vertex stage and dot it with the view axis `(0, 0, 1)`. + Mixing spaces silently breaks anything distance-based — this was the + root cause of the hit-flash ring never rendering. +- **`pow(x, 2.0)` is undefined for `x < 0`** and produces NaN on this + Metal backend — one NaN kills the whole fragment (and blending against + it). Gaussians written as `exp(-pow(d, 2.0))` over a *sign-changing* + `d` (rings, bands) therefore never render. Always use `exp(-d * d)` with + `d` computed on its own line. +- **Additive-blend materials pass through the engine's HDR exposure before + tonemapping** — an effective ~100–300× lift on `rgb * alpha` — while + `transparent` and `opaque` materials render in the plain 0–1 range. + Tune additive materials with artistic 0–4 amplitude and a final + `* 0.05`-ish scale factor (see `hit_flash.mat` / `shockwave_ground.mat`); + a "reasonable-looking" 0.2 output saturates to pure white and the ACES + curve desaturates it on top. Camera EV does **not** help (it only scales + photometric lights); color-grading exposure does, but rescaling in the + shader is simpler. +- **`setParameterFloat*` on a uniform that is not declared in the `.mat` + hard-crashes the native layer** (`PreconditionPanic: uniform named "…" + not found` in `getFieldInfo`), killing the process mid-setup. If a + render comes out as bare skybox with no geometry and the log ends + abruptly, check for a Dart↔`.mat` parameter-name mismatch first. +- **Still mode runs the animators at the requested `--time`** (default 0), + which overwrites any uniform values the setup applied — the "golden + time" a setup sets on its instances only survives until the animator + call. Pass `--time` explicitly to capture the state you want. +- `capture()` **bypasses `registerRequestFrameHook` hooks** (it calls the + renderer directly), and its `beforeRender` callback is **not awaited**. + That is why animation for stills/video goes through `effectAnimators` + (below), and why stills need `--time`. +- **Destroying a MaterialInstance still assigned to a renderable + deadlocks**, and Filament panics if a material is destroyed while + instances remain alive. The runner therefore `exit(0)`s right after + saving instead of tearing down. +- Do not put `skinning` in a material's `variantFilter` if it may be applied + to skinned meshes — BusterDrone is skinned, and the engine aborts with + `Requested variant (SKN) does not exist`. +- `.mat` `variables` are **float4** (use `.xyz`); fragment-stage `getUV0()` + is only vec2 — pass extra per-fragment data via a custom `variables` + channel instead. Vertex-stage `getPosition()` is **float4** (use `.xyz`). +- `GeometryUtils.sphere` has **radius 1.0** (not 0.5). +- `viewer.loadIbl()` installs its **own skybox** — call it *before* + `setDarkSkybox` if you want the dark background to win. +- The colored skybox renders brighter than its linear values suggest + (default exposure + tonemapping); `setDarkSkybox` uses deliberately tiny + values (0.004–0.012) for this reason. The same tonemapping is why the + FlightHelmet under `default_env_ibl.ktx` blows out to pure white — the + hit-flash scene uses a dim directional light instead so the additive + flash keeps contrast. +- `highlight_effects` hangs in the headless runner (pre-existing, unrelated + to this work); `load_gltf` and all `game_effects_*` examples work. + +## 4. Architecture in one paragraph + +A `.mat` source is compiled offline by matc into a self-contained +`.filamat`; the setup loads those bytes with +`FilamentApp.instance!.loadResource(...)` → `createMaterial(bytes)` → +`createInstance()`, sets uniforms via `MaterialInstance.setParameter*`, and +either swaps the instance onto a loaded glTF asset +(`setMaterialInstanceForAll`) or creates geometry with it +(`viewer.createGeometry(Geometry, materialInstances: [...])`). Each setup +also registers a **time animator** — a `Future Function(double t)` in +`effectAnimators` — mapping wall-clock seconds to uniform writes; the +runner's still mode applies the animators at `--time` (default 0) before +capturing, and its `--video` mode steps `t` per frame before each capture. +Blending modes are baked into the `.mat` (`add`, `transparent`, +`opaque`); everything else is a uniform you can retune from Dart without +recompiling. Each setup enables the post-processing stack through +`enableVfxPost`, with effect-specific bloom strength: emissive fire/lava/ +shockwave get a stronger halo, while water, smoke, and shore use restrained +bloom so highlights soften without flattening the image. + +## 5. Shared helpers (`game_effects_shared.dart`) + +| Helper | Purpose | +|---|---| +| `EffectClock` | Stopwatch-based clock with `tick()` (live hooks) and `setTime(t)` (deterministic stills) | +| `effectAnimators` | per-setup time→uniform closures consumed by the runner's still (`--time`) and video modes | +| `loadEffectMaterial(viewer, assetsDir, name)` | `.filamat` bytes → `MaterialInstance` | +| `setDarkSkybox(viewer)` | near-black navy skybox for additive scenes | +| `enableVfxPost(viewer, bloomStrength)` | enables AA/tonemapping/bloom with a per-effect glow strength | +| `subdividedPlane(w, d, subX, subZ)` | flat XZ grid with normals/UVs (`GeometryUtils.plane` is only 4 verts) | +| `dummyBillboardQuads(n)` | near-degenerate mesh: 6 verts per puff placed at a small deterministic scatter (valid bounding volume, treatable as zero by the shader — see the worldPosition gotcha) | + +--- + +## 6. The effects + +### 6.1 hit_flash — `game_effects_hit_flash.dart`, `hit_flash.mat` + +**Intent.** Damage feedback: a white-hot flash at the impact point with a +saturated shockwave ring expanding outward across the mesh while a +rim-weighted body flash decays — the classic "I got hit" read. In the video +a hit lands every 2.4s (0.55s flash, rotating through three impact points on +the camera-facing side, then the normal PBR look until the next hit). + +**Build.** Unlit + **additive** blend, depthWrite off, back-face culled, +using Filament's two-pass one-sided transparency. The vertex stage +passes the world normal and true world position (model matrix × object +position — see the view-space gotcha) via `variables`. The fragment +computes a fast cubic-decay body flash weighted to the silhouette, a +**localized hotspot** at `hitPoint` (dies fastest — it's what says "hit +HERE"), and a **shockwave ring** whose radius sweeps from ~0.25 (the impact +point sits just inside the surface) to ~1.4 (the mesh's far side) over the +flash, with a sharp leading edge and a tight warm wake behind it. The ring +color is an *oversaturated* version of `flashColor` — bright additive +values get desaturated toward white by the tonemapper, so the sweep needs +excess input to read as colored. Output is scaled ~0.05 for the additive +HDR exposure path (see the gotcha). The original PBR helmet remains in the +scene; a coincident second copy carries the flash as a true overlay. Its +vertices are lifted by `normalOffset`, while the two-pass depth path keeps +the complex glTF's internal submeshes from showing through as an X-ray. +The scene uses a warm key and cool point fill so the resting helmet stays +readable between hits. + +**Uniforms.** `flashColor` float4 (default orange 1.0/0.36/0.1), `hitPoint` +float3 (world), `progress` float (0 = impact instant, 1 = finished), and +`normalOffset` (0.006). Flash duration, hit period and the impact-point list +are Dart constants. + +**Tuning.** Snappier ring → raise the `15.0` width base or `1.15` travel in +the `.mat`; stronger directionality → move `hitPoint`; colored per-damage +type → change `flashColor` at runtime. Golden still time: `--time 0.22` +(ring mid-sweep across the face). + +**Limitations / ideas.** The example duplicates the glTF because the viewer +does not yet expose a shared-geometry overlay renderable. A screen-space +chromatic pulse and a decal-style scorch mark are natural follow-ons. + +### 6.2 hologram — `game_effects_hologram.dart`, `hologram.mat` + +**Intent.** Sci-fi projection: translucent cyan shell with a readable +silhouette, fine scanlines sweeping upward, a bright scanning band +traveling up the model, occasional glitch bands that shear and split +chromatically, subtle flicker with rare dropouts. + +**Build.** Unlit + **transparent** blend (premultiplied output), +depthWrite off, double-sided so the back shell adds depth. The fragment +combines: a fresnel rim kept saturated (brighten via alpha, never mix +toward white — tonemapping desaturates brights); two scanline layers +(a dense fine set plus a slow coarse set); a gaussian **scanning band** +with a soft haze and a sharp trailing bar sweeping y over ~4.8s; gated +**glitch bands** (~36% of 2.5Hz time slices) — two bands tear in different +directions, each with a complementary chromatic split (blue-fringed one +way, red-fringed the other); and a two-frequency flicker with rare +single-frame dropouts. The gate hash is duplicated verbatim in both stages +so the vertex shear and fragment flare stay in sync. A slight hue journey +runs up the projection: deeper blue at the base, near-white cyan at the +crown. Applied to the skinned BusterDrone via material swap. A separate +additive `hologram_projector.mat` disc beneath the drone supplies concentric +rings, radial spokes, a rotating acquisition sweep, and a physical visual +source; the camera drifts on a subtle orbit during video. + +**Uniforms.** `tintColor` (0.2, 0.85, 1.0), `time`, `fresnelPower` 2.5, +`fresnelStrength` 1.35, `scanlineCount` 70, `scanlineSpeed` 4.0, +`glitchAmount` 0.09. Golden still time: `--time 2.2` (glitch slice active, +band mid-model). + +**Tuning.** More aggressive glitch → raise `glitchAmount` or lower the +`0.72` gate threshold; denser lines → higher `scanlineCount` (watch Moiré +on small meshes); the sweep speed is the `0.21` factor on `t`. + +**Limitations / ideas.** A materialization wipe (discard below a +noise-edged Y threshold that rises with time) would give a projector boot-up; +a translucent volumetric cone between the new emitter and drone is the next +scene-layer improvement. + +### 6.3 force_field — `game_effects_force_field.dart`, `force_field.mat` + +**Intent.** A shield bubble around a generator core: dark see-through +interior, bright fresnel silhouette with a hot rim lip, a curved energy +lattice with pulses running along its lines, and an impact response — a +splash at the hit site, then a sharp expanding ring (with a trailing echo) +that flares the lattice as it crosses. + +**Build.** Unlit + **additive**, depthWrite off, on a 48×64 sphere scaled +to radius 1.2, front-face shell only so the back grid cannot muddy the read. +The lattice is two warped families of thin spherical arcs with directional +power flow and brighter intersections; unlike the original spherical hex +mapping it neither pinches into giant polar cells nor reveals the source +triangulation. The impact response adds a directional splash, a sharp +angular ring, a trailing echo, and nine rotating arc sparks around the ring, +all flaring the lattice as they pass. A dedicated `force_core.mat` shades a +rotating low-poly crystal with animated energy scans instead of relying on a +featureless default white cube. + +**Uniforms.** `baseColor` (0.30, 0.55, 1.0), `time`, `fresnelPower` 2.2, +`hexScale` 16 (arc density), `hexStrength` 1.25, +`hitDirection` float3 (unit), `hitAge` float (seconds since hit). Golden +still time: `--time 2.25` (ripple mid-expansion). + +**Tuning.** Denser arcs → higher `hexScale`; stronger shield presence → +the rim gains or final additive scale; ripple speed/decay → the `2.05` and +`0.75` factors on `hitAge`. + +**Limitations / ideas.** Multiple simultaneous ripples (a small ring buffer +of hit directions/ages) would support sustained fire; a real protected PBR +subject inside the shell would give the shield stronger gameplay context. + +### 6.4 dissolve_burn — `game_effects_dissolve_burn.dart`, `dissolve_burn.mat` + +**Intent.** Death/teleport: the mesh is eaten away by irregular noise +while the receding front burns — white-hot at the very edge through orange +to deep red, with charred ground just ahead of the front and occasional +ember sparks. + +**Build.** Unlit + **opaque** with `discard` below the threshold (keeps +depth-writing correct, unlike alpha blending). The noise is 4-octave fbm +sampled in **object space** (pinned to the mesh — a moving model slides +*through* the burn, the burn never slides across the model), slowly +scrolling in Z via `time`. The fragment computes the normalized distance +`e` from the threshold over `edgeWidth` and builds a three-stop +**temperature gradient** (white-hot → `edgeColor` orange → deep red) with +two-octave combustion flicker; ahead of the front the surface darkens +through a `charr` gradient concentrated in a band about 3× the edge width, +and rare twinkling ember sparks sit right at the front. The video ramps +`threshold` 0→0.95 over a 3.5s loop. + +**Uniforms.** `baseColor` (0.10, 0.075, 0.06 — char), `edgeColor` +(1.0, 0.45, 0.1), `threshold` 0.5, `edgeWidth` 0.065, `edgeIntensity` 1.35, +`noiseScale` 3.4, `time`. Golden still time: `--time 1.2`. + +**Tuning.** Chunkier dissolution → lower `noiseScale`; hotter edge → raise +`edgeIntensity`/narrow `edgeWidth`; wider charred apron → raise the `3.0` +multiplier on `edgeWidth` in the `charr` smoothstep. + +**Limitations / ideas.** Rising ember sparks (reuse the smoke billboard +system with an upward, shrinking preset) and ash fall are natural +follow-ons; a directional burn (bias the threshold by a world-space +gradient) would read as spreading fire rather than uniform decay. + +### 6.5 water — `game_effects_water.dart`, `water.mat` + +**Intent.** Stylized game water: rolling swells with visible crest/trough +relief, deep color looking down with drifting current variation, +sky-reflecting grazing angles, a directional sun glitter path with real +sparkle, whitecaps on the choppiest crests, and a rim that melts into the +horizon. + +**Build.** The heaviest vertex shader: five summed **Gerstner waves** +(~1×/2×/4×/7×/11× frequency, rotated directions, per-wave steepness pushed +high enough for sharp crests without looping) displace a 24×24-unit, +240×240-vertex `subdividedPlane` — as a pure `+=` displacement +(see the worldPosition gotcha). Normals come from finite differences, and +the same differences feed **two foam inputs** packed into the variables' +`.w` channels: crest height and horizontal compression (where Gerstner +pinch concentrates is exactly where whitecaps live). The fragment shader +adds **three scrolled fbm detail-normal layers** (attenuated with distance +to avoid aliasing), Schlick fresnel deep→sky mixing over a large-scale +**current-noise body color**, sun-side shading plus crest lightening, a +backlit subsurface glow on crests toward the sun, dual-lobe sun glitter — +broad sheen plus a fresnel-weighted sparkle that is both twinkle-hashed +and **gated into a wedge pointing at the sun** so it reads as a glitter +path — and foam from crest+chop broken by two noise octaves (foam also +flattens the normals — foam is diffuse, and carries its own shadow tint). +The horizon **fades alpha to zero** at the rim so the plane edge never +reads as a disc against the skybox. Unlit + **transparent**, depthWrite +on, double-sided. + +**Uniforms.** `deepColor` (0.008, 0.058, 0.090, 0.94), `skyColor` +(0.36, 0.52, 0.72), `foamColor` (0.94, 0.98, 1.0), `sunDirection` +(−0.55, −0.35, −0.75), `time`, `waveHeight` 0.27, `waveFrequency` 1.12, +`waveSpeed` 1.6, `foamAmount` 0.72, `specularPower` 520 (tight-lobe), +`specularIntensity` 2.5, `detailStrength` 0.82, `sssStrength` 0.9. Camera +at (0, 1.8, 5). Golden still time: `--time 1.7`. + +**Tuning.** Stormier → raise `waveHeight`/`waveFrequency` (watch grid +density); milder glints → lower `specularIntensity`; calmer detail → +lower `detailStrength`. The Gerstner directions/frequency ratios are +hard-coded in the `.mat` vertex block — parameterizing them is a +straightforward first improvement. + +**Limitations / ideas.** "Sky reflection" is a flat color — the big upgrade +is real environment reflections (switch to `shadingModel : lit` with +`reflectionFactor`, or sample an IBL manually) or a planar reflection via a +render target. Depth-based color/soft shore foam needs a depth pre-pass or +shore-distance vertex attribute. Wispy foam streaks along wave faces would +need flow-map-style advection of the foam noise. + +### 6.6 smoke — `game_effects_smoke.dart`, `smoke.mat` + +**Intent.** A living gray smoke column: puffs spawn small and dense at the +base, rise and accelerate, stretch vertically, spiral outward and bend with +the wind while thinning to wisps — one draw call, no particle system (the +vendored Filament has none). + +**Build.** Fully **GPU-generated geometry**: the CPU mesh is +`dummyBillboardQuads(64)` — near-zero (but non-degenerate) vertices whose +displacement the shader treats as purely additive (see the worldPosition +gotcha). The vertex shader reconstructs everything from `getVertexIndex()`: +`vid / 6` is the puff, `vid % 6` picks from constant quad corner/UV tables; +per-puff hash seeds stagger lifetime and vary rise/stretch/spin/brightness; +`mod(time − seed·lifetime, lifetime)` loops each puff independently. The +puff center is a **cone + spiral + wind bend** (`age·0.36` spiral radius, +`age²·0.62` downwind drift) — this is what makes it read as a plume rather +than a vertical line of blobs. Billboards face the camera along the rows +of `getViewFromWorldMatrix()`. The fragment shader shapes each quad with a +radial falloff × **vertically-squashed, double domain-warped fbm** (the +squash stretches features into rising wisps; the double warp tears the +edges), eases alpha in/out over the lifetime, and carries age/brightness +in the custom `quadData` variable's zw channels. Young puffs get a warm +cast as if lit from the fire below and scatter more light at their tops; +old puffs cool toward blue-gray translucency. **Unlit + transparent** +(premultiplied): overlapping puffs build to denser gray instead of +additively washing to white (the additive version was a white blob). + +**Uniforms.** `baseColor` (0.23, 0.25, 0.30 — blue-gray), `time`, `puffCount` 64 +(informational — must match the Dart constant you pass to +`dummyBillboardQuads`), `riseSpeed` 0.42, `expandSpeed` 0.105, +`swirlAmount` 1.35, `baseSize` 0.18, `noiseScale` 3.4, `lifetime` 5.2, +`originHeight` 0, `opacity` 1. +Camera at (0.35, 1.3, 3.6) focused slightly above the column base. Golden +still time: `--time 4.6` (fully populated column). + +**Tuning.** Denser column → more puffs (bump the Dart constant — +`puffCount` is informational only); wind → the `age·age·0.62` term; +darker/sootier smoke → lower `baseColor`; the noise warp strength is the +`1.7` multiplier on `warp`. + +**Limitations / ideas.** No depth-fade (puffs cut hard against intersecting +geometry) — the proper fix is soft particles sampling the depth buffer +(see the `render_targets` example for depth access). The single-draw-call +order is unsorted, which alpha blending mostly forgives for soft puffs. +Fog and rain presets are parameter tweaks away. + +### 6.7 fire — `game_effects_fire.dart`, `fire.mat` + +**Intent.** A campfire-style flame: two rings of tongues (a tight hot core +and a looser skirt) licking upward from a granular white-hot ember bed, +through yellow and orange to saturated red tips, with ember sparks (a few +big ones) rising out of it. + +**Build.** One draw call on `dummyBillboardQuads(48)`: the first 12 quads +are flame tongues, the remaining 36 are embers — the vertex shader branches +on `quadIndex < flameCount`. The first, broad quad is a coherent +noise-eroded flame body; narrower licks layer over it. Tongues stand on their base, each with its own +height/flicker phase and a slowly wandering cluster center so the fire +breathes rather than flickering in place; the cluster is sheared by wind +proportionally to height. The fragment shader scrolls domain-warped fbm +**downward** in noise-space through a vertically-squashed domain (tall +narrow licks), tapers the width with height, lets the noise eat aggressively +into the tip so it ends in separated licks, adds a **granulated ember bed** +at the base, and colors the heat field through a blackbody ramp (deep red → +orange → yellow → white) with saturated red at the dying tips and a whisper +of blue where cold fuel enters at the very base. Embers rise from inside +the flame with a sinusoidal wobble, stretching into little streaks, +flickering, and cooling white → red; a few (1 in 10) run big. A second draw +reuses `smoke.mat` as a raised, lower-opacity combustion plume, while +`fire_ground.mat` adds a pulsing coal/crack bed under the flame. Unlit + +**additive** with the HDR-path output scale for flame, embers, and ground. + +**Uniforms.** `time`, `flameCount` 12, `emberCount` 36, `flameHeight` 1.12, +`flameWidth` 0.27, `noiseScale` 3.4, `scrollSpeed` 2.5, `windLean` 0.22, +`emberLifetime` 1.9. The counts must match the geometry passed to +`dummyBillboardQuads`. Golden still time: `--time 4.6`. + +**Tuning.** Taller/lazier flames → raise `flameHeight`, lower +`scrollSpeed`; windier → `windLean`; hotter core → stretch the ramp stops +in the `.mat`. + +**Limitations / ideas.** A flickering point light driven from the same phase +and nearby receiving geometry would sell the heat in a gameplay scene; +soft-particle depth intersection remains the main smoke limitation. + +### 6.8 lava — `game_effects_lava.dart`, `lava.mat` + +**Intent.** A molten field: dark drifting crust broken by a **connected +web** of glowing cracks (like real cooling lava), hottest (yellow-white) in +the fast-flowing channels, with a red reheat halo bleeding into the crust. + +**Build.** A vertex-displaced `subdividedPlane` (additive displacement +only): three slow drifting sine lumps form the crust swell, with +finite-difference normals. The cracks are the **ridges of two domain-warped +fbm fields, combined with `max()`** — ridge lines form a connected, +branching web rather than the isolated blobs a plain threshold produced. +A wider, dimmer ridge halo gives the reheat apron. Crack interiors are +modulated by two scrolled flow noises; their sharpest lobes (`flow²`) +drive near-white hot cores, so the hottest points visibly course along the +channels. Temperature ramp deep red → orange → yellow-white keyed on crack +intensity + core; the crust is very dark red-brown (exposure + ACES lift +darks heavily — see the skybox gotcha — so both crust and ramp stops are +pushed darker/more saturated than the intended read) with two octaves of +grain, gentle top-light shading, and a slow regional convection pulse. +Opaque, edge melts into the background. + +**Uniforms.** `time`, `glowIntensity` 1.12, `crustScale` 1.0, `flowSpeed` +0.5, `swellHeight` 0.14. Golden still time: `--time 3.0`. + +**Tuning.** More/denser cracks → raise the `0.80/0.84` ridge thresholds; +faster crust drift → the `0.05/0.04` time factors; more violent swell → +`swellHeight`. + +**Limitations / ideas.** Ember/smoke vents on the brightest cracks (reuse +the fire/smoke rigs); heat-haze refraction needs post-processing this +example level doesn't have. + +### 6.9 shockwave — `game_effects_shockwave.dart`, `shockwave_ground.mat` + `shockwave_dome.mat` + +**Intent.** An ultimate-style energy pulse: a hot flash at the epicenter, +then a sharp arc-broken ring racing out across the ground while an energy +dome expands out of the epicenter — one coordinated event every 2.2s. + +**Build.** Two materials on two geometries. The **ground ring** (flat +plane at the identity transform, so its vertex stage passes +`getPosition()` straight through as world position — see the view-space +gotcha): one wave per period, `waveR = age·speed`, with a sharp leading +edge whose width relaxes as the ring travels (constant readability, not +constant world width), a trailing secondary ring, a lingering energy fill +behind the front, a hot **epicenter flash** at the instant of the pulse, +angular fbm breaking the ring into arcs with contrast pushed so the gaps +go fully dark, and outward-advected churn noise on the front. The **dome** +(unit sphere, scaled by the Dart animator to track the ring front): +fresnel body + hot rim computed **in the vertex stage** from the +view-space normal (no fragment position needed — see the gotcha), +upward-flowing vertical streak noise, and a hot **equator lip** where the +dome meets the ground; its **lower hemisphere is discarded at y=0** in +object space (scale-invariant), so the equator cut sits exactly on the +ground plane. Both are additive with the HDR-path output scale. + +**Uniforms.** Ground: `time`, `period` 2.2, `waveSpeed` 3.6. Dome: +`baseColor`, `time`, `age` (seconds since pulse). The dome's scale is set +per-frame by the animator (`0.15 + age·waveSpeed·0.78`). Golden still +time: `--time 0.55` (ring mid-frame, arcs legible). + +**Tuning.** Faster pulse → `waveSpeed`; sharper ring → the `4.6` width +base; longer-lived dome → the `1.3` fade rate. + +**Limitations / ideas.** Trigger the pulse from an actual game event (the +animator is trivially rewirable); ground scorch would want an alpha-blended +decal pass; multiple overlapping waves need a per-wave uniform array or a +ring buffer of ages. + +### 6.10 shore_waves — `game_effects_shore_waves.dart`, `shore_waves.mat` + `sand.mat` + +**Intent.** Waves arriving at a beach: swells shoal (grow) as they reach +the shallows, whitecap on the way in, then break into a foam line that +**travels toward the shore** in pulses and washes up onto wet sand whose +swash line runs up and back in lockstep. + +**Build.** The trick that keeps this cheap: **the demo owns the scene +geometry, so the shoreline is an analytic function** — `z_shore(x) = +1.2 + 0.35·sin(0.6x + 1) + 0.15·sin(1.7x)` — and "shore distance" is just +that minus world z (no depth texture). The vertex shader runs three +Gerstner swells **traveling toward the shore** with a per-vertex amplitude: +growing through the shoaling zone, collapsing past the break. The fragment +shader mixes deep teal → turquoise by shore proximity (a deliberately +tight transition), adds Schlick fresnel, sun-side shading, two +detail-normal layers and glitter toward the beach. Foam has three sources: +whitecaps (crest + pinch, as in water), the **breaking line** — a gaussian +band around the shoreline whose pulse crest travels **toward the shore** +(`sin(t·3 + depth·2.8 + along-shore wobble)`, so the break line advances +unevenly rather than blinking in place), textured with two anisotropic +noise octaves stretched along the shore — a second, lower irregular breaker +approaching behind it, and a wash residue further up. +The waterline itself is noise-fingered (jagged, not straight) and +alpha-fades past the shoreline into a **land-side sand plane** (`sand.mat`) +that is clipped by the same analytic contour, preventing displaced troughs +from exposing false sand islands. Its swash band center oscillates +with the *same phase* as the breaker pulse runs up the sand, trailed by a +damp apron and foam speckle at its leading edge, so waterline, breaker and +swash all stay in lockstep. Water: unlit + transparent, depthWrite on. + +**Uniforms (water).** `deepColor` (0.008, 0.07, 0.12), `shallowColor` +(0.025, 0.30, 0.34), `skyColor` (0.075, 0.14, 0.22), `foamColor` +(0.40, 0.52, 0.58), `sunDirection` (−0.45, −0.35, −0.8), `time`, +`waveHeight` 0.24, `waveFrequency` 1.35, `waveSpeed` 1.5, `foamAmount` 0.72, +`detailStrength` 0.78. Sand: `sandColor` (0.085, 0.050, 0.022), `time` +(driven by the same animator, phase-locked). Camera at (0, 4.5, −4.8) looking +shoreward. Golden still time: `--time 2.0`. + +**Tuning.** Bigger surf → `waveHeight`/`waveFrequency`; wider breaker → +the `0.38` band width; slower sets → the `3.0` pulse rate; the shoreline +curve itself is the `shoreZ` function (must stay identical in both +`.mat`s). + +**Limitations / ideas.** The swells don't actually refract/curve toward +the beach contour (real wave optics); foam advection (flow-map style) +would give trailing streaks behind the break line. + +### 6.11 wetness — `game_effects_wetness.dart`, `wetness.mat` + +**Intent.** Rain-soaked aggregate with irregular pooled water rather than a +uniformly glossy surface. The wet areas have a second clear-coat lobe, tight +grazing reflections, multiple independently timed ripple fields, and rough +grit visible beneath the water. + +**Build.** Manually lit unlit material on a dense ground grid. Four-octave fbm +defines puddles; two aggregate scales break up the substrate; three hashed cell +grids spawn rings without CPU particles. Puddle coverage drives base-color +darkening, grazing reflection, specular streaks, and micro-glitter together. +This manual path is intentional: custom lit variants fail to load on the +current feature-level-1 Metal runtime. Golden still: `--time 2.35`. + +### 6.12 crystal_ice — `game_effects_crystal_ice.dart`, `crystal_ice.mat` + +**Intent.** A faceted hero ice crystal with a cool inner volume, spectral +edge separation, and energized fissures that travel through the silhouette. + +**Build.** A deliberately coarse 12×18 sphere makes the macro facets read; +3D Voronoi provides inner crystalline breakup, two intersecting analytic +fracture families form the cracks, and a view-dependent cyan/violet fresnel +creates the refractive read without requiring a screen-color texture. Golden +still: `--time 1.8`. + +### 6.13 snow_accumulation — `game_effects_snow_accumulation.dart`, `snow_accumulation.mat` + +**Intent.** Snow that gathers from above on a complex asset instead of a +white material cross-fade. + +**Build.** A manually lit material combines world-normal slope, object-space +height, wind-scale noise, and a moving accumulation line. Covered areas get a +broad diffuse snow response; exposed areas remain a dark metal/oxide substrate. +Fine frost cells add sparse cool sparkle. The manual light model avoids the +same custom-lit Metal variant limitation noted for wetness. Golden still: +`--time 1.5`. + +### 6.14 damage_decals — `game_effects_damage_decals.dart`, `damage_decals.mat` + +**Intent.** A readable sequence of projectile impacts with permanent damage +and short-lived thermal response. + +**Build.** Four independently timed analytic decals combine asymmetric holes, +beveled rims, soot gradients, and nine seeded fracture rays per impact. The +fresh hit blooms white-orange and cools while the crater and scorch remain. +The showcase bakes the receiver and decals into one procedural material; a +game integration would feed the same masks through its decal/DBuffer pass. +Golden still: `--time 2.05`. + +### 6.15 portal_rift — `game_effects_portal_rift.dart`, `portal_rift.mat` + +**Intent.** A portal that reads as depth, flow, and dangerous boundary energy, +not a flat rotating texture. + +**Build.** Polar fbm warps counter-rotating tunnel rings and spiral filaments; +hashed star motes reinforce parallax; separate rim and corona profiles create +the high-energy lip. `openAmount` collapses the horizontal axis for a staged +slit-to-disc opening and closing. Golden still: `--time 2.2`. + +### 6.16 electricity — `game_effects_electricity.dart`, `electricity.mat` + +**Intent.** A coherent lightning discharge with a stepped trunk, visible side +forks, a hard HDR core, and softer ionized air. + +**Build.** Sixty-four degenerate CPU quads become oriented bolt segments in the +vertex shader. A stable multi-frequency path is perturbed at an 18 Hz cadence; +the remaining segments form three-link branches on alternating sides. Fragment +distance shapes the core and halo in one additive draw. Golden still: +`--time 1.7`. + +### 6.17 invisibility_cloak — `game_effects_invisibility_cloak.dart`, `invisibility_cloak.mat` + +**Intent.** Restrained active camouflage that almost disappears at rest but +gives gameplay-readable chromatic edges and intermittent hardware faults. + +**Build.** Transparent, depth-write-off shading keeps only a faint body while +fresnel supplies the silhouette. Scan faults, cellular breakup, a traveling +interference band, and small normal-direction vertex shimmer spike during a +periodic disruption envelope. Golden still: `--time 2.15`. + +### 6.18 energy_weapon — `game_effects_energy_weapon.dart`, `energy_weapon.mat` + +**Intent.** A complete shot lifecycle: pre-charge, muzzle bloom, plasma beam, +traveling core structure, impact orb, and expanding shock shell. + +**Build.** One wide additive pass shares a single coordinate system for muzzle, +beam, and impact. Dart drives the fire envelope over a 2.6 s cycle; the shader +derives pre-charge and impact timing from the same clock, adding a turbulent +envelope around a tight blue core, traveling packets, an expanding muzzle ring, +and polar impact rays. The single pass avoids cross-mesh transform drift and is +straightforward to connect to weapon events. Golden still: `--time 1.2`. + +--- + +## 7. Suggested roadmap + +1. **hit_flash**: overlay pass (keep PBR visible during the flash); + screen-space chromatic pulse on impact. +2. **water**: IBL/lit reflections, depth-based color, flow-advection for + wispy foam streaks. +3. **smoke**: soft-particle depth fade. +4. **force_field**: multiple simultaneous ripples; cube-map-projected + lattice. +5. **dissolve_burn**: directional burn gradient; ember sparks via the + smoke system. +6. **hologram**: materialization wipe; projector cone/disc geometry. +7. **fire**: smoke cap via the smoke rig; flickering scene light driven + from Dart. +8. **shockwave**: event-triggered pulses; overlapping waves. +9. **shore_waves**: wave refraction toward the beach contour; foam + advection behind the break line. +10. New effects from the same toolkit: cloth/flags, grass, heal circles, + volumetric fog shafts, stylized clouds, and caustic projectors. +11. Cross-cutting: a `game_effects` composite galleryScene for the web + gallery; web verification (COOP/COEP, real Chrome) once a WebGPU matc is + available; interactive parameter playground. + +## 8. Commit map (as of writing) + +- `d5e68e060` — `fix: make materials/build.sh work on macOS` +- `7061362fb` — the six materials + example setups + plan doc +- `7c976d39e` — `effectAnimators` + `--video` runner mode + hit_flash lighting +- `a5fdbe2b2` / `a82173a5c` — first visual overhaul + fire/lava/shockwave/ + shore waves +- (this branch) — visual-quality overhaul round two: hit flash with a real + orange shockwave ring + localized hotspot (and the view-space + `getWorldPosition` / negative-`pow` / additive-HDR-exposure fixes that + had silently killed it), hex force field at readable scale with + splash+ring+echo impact, two-band glitch hologram, gray alpha-blended + smoke column, two-ring fire with granular ember bed and red tips, + ridged-web lava with flowing hot cores, crisper dissolve front, arc- + broken shockwave with epicenter flash + streaked dome, traveling breaker + with phase-locked sand swash, water with currents/textured foam/ + directional glitter path/fading horizon. diff --git a/examples/assets/crystal_ice.filamat b/examples/assets/crystal_ice.filamat new file mode 100644 index 000000000..5b0335383 Binary files /dev/null and b/examples/assets/crystal_ice.filamat differ diff --git a/examples/assets/crystal_ice.mat b/examples/assets/crystal_ice.mat new file mode 100644 index 000000000..b8a8658f7 --- /dev/null +++ b/examples/assets/crystal_ice.mat @@ -0,0 +1,56 @@ +material { + name : CrystalIce, + shadingModel : unlit, + requires : [ position, tangents ], + variables : [ objectPos, surfaceNormal, worldPos ], + parameters : [ + { type : float, name : time }, + { type : float4, name : tint } + ], + blending : transparent, + depthWrite : true, + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} +vertex { + void materialVertex(inout MaterialVertexInputs material) { + material.objectPos.xyz = getPosition().xyz; + material.surfaceNormal.xyz = material.worldNormal; + material.worldPos.xyz = material.worldPosition.xyz; + } +} +fragment { + float hash(vec3 p) { p = fract(p * .1031); p += dot(p, p.yzx + 33.33); return fract((p.x+p.y)*p.z); } + float voronoi(vec3 x) { + vec3 n = floor(x), f = fract(x); float md = 8.0; + for (int k=-1;k<=1;k++) for (int j=-1;j<=1;j++) for (int i=-1;i<=1;i++) { + vec3 g=vec3(float(i),float(j),float(k)); + vec3 o=vec3(hash(n+g),hash(n+g+17.0),hash(n+g+41.0)); + md=min(md,length(g+o-f)); + } + return md; + } + void material(inout MaterialInputs material) { + prepareMaterial(material); + vec3 p = variable_objectPos.xyz; + vec3 n = normalize(variable_surfaceNormal.xyz); + vec3 v = normalize(getWorldCameraPosition() - variable_worldPos.xyz); + float fres = pow(1.0 - abs(dot(n, v)), 2.6); + float cells = voronoi(p * 4.2); + float facets = smoothstep(.12, .72, cells); + float fractureA = 1.0 - smoothstep(.012, .038, abs(p.x*.72+p.y*.31-p.z*.28-.18)); + float fractureB = 1.0 - smoothstep(.01, .032, abs(p.x*.22-p.y*.37+p.z*.81+.26)); + float branchGate = smoothstep(.35,.8,sin(p.y*17.0+p.z*8.0)*.5+.5); + float crack = clamp((fractureA*branchGate + fractureB*(1.0-branchGate)) * smoothstep(.28, .72, length(p)), 0.0, 1.0); + float inner = .5 + .5 * sin(dot(p, vec3(8.0, 13.0, 17.0)) - materialParams.time * .9); + vec3 deep = materialParams.tint.rgb * (.05 + .09 * facets); + vec3 spectral = mix(vec3(.03,.35,.75), vec3(.48,.08,.85), .5 + .5 * dot(n, vec3(.57,.0,.82))); + vec3 color = deep + spectral * fres * .42; + color += vec3(.48,.85,1.0) * crack * (.38 + .12 * sin(materialParams.time * 4.0 + p.y * 20.0)); + color += materialParams.tint.rgb * inner * .035; + float inclusion=step(.992,hash(floor(p*37.0)))*(.3+.7*fres); + color+=vec3(.55,.8,1.0)*inclusion*.38; + float alpha = clamp(.22 + fres * .5 + crack * .18, 0.0, .82) * materialParams.tint.a; + material.baseColor = vec4(color * alpha, alpha); + } +} diff --git a/examples/assets/damage_decals.filamat b/examples/assets/damage_decals.filamat new file mode 100644 index 000000000..b538d4d04 Binary files /dev/null and b/examples/assets/damage_decals.filamat differ diff --git a/examples/assets/damage_decals.mat b/examples/assets/damage_decals.mat new file mode 100644 index 000000000..c9a19b1e3 --- /dev/null +++ b/examples/assets/damage_decals.mat @@ -0,0 +1,49 @@ +material { + name : DamageDecals, + shadingModel : unlit, + requires : [ position ], + variables : [ objectPos ], + parameters : [ { type : float, name : time } ], + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} +vertex { void materialVertex(inout MaterialVertexInputs material) { material.objectPos.xyz=getPosition().xyz; } } +fragment { + float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);} + float impact(vec2 p, vec2 c, float born, inout vec3 color) { + vec2 q=p-c; float r=length(q), a=atan(q.y,q.x); + float age=clamp((materialParams.time-born)*2.4,0.0,1.0); + float shape=r*(1.0+.045*sin(a*7.0+hash(c)*6.0)+.025*sin(a*13.0)); + float hole=1.0-smoothstep(.035,.105,shape); + float bevel=smoothstep(.055,.09,shape)*(1.0-smoothstep(.105,.15,shape)); + float scorch=(1.0-smoothstep(.10,.34,shape))*smoothstep(.065,.13,shape); + float cracks=0.0; + for(int i=0;i<9;i++){ + float fi=float(i); float ang=fi*.698+hash(c+fi)*.42; + float line=abs(sin(a-ang)); + float broken=step(.42,hash(vec2(floor(r*28.0),fi)+c*9.0)); + cracks=max(cracks,(1.0-smoothstep(.0,.011,line))*smoothstep(.09,.14,r)*(1.0-smoothstep(.16,.44,r+hash(c+fi)*.14))*broken); + } + float fresh=exp(-max(materialParams.time-born,0.0)*2.3); + float sideLight=.25+.75*max(dot(normalize(q+vec2(.0001)),normalize(vec2(-.55,.83))),0.0); + color=mix(color,vec3(.001,.0005,.0003),max(hole,scorch*.62)*age); + color+=vec3(.36,.075,.006)*bevel*fresh*age*sideLight; + color=mix(color,vec3(.003,.0015,.001),cracks*age); + color+=vec3(.42,.07,.004)*bevel*fresh*age*.42; + return max(max(hole,scorch),cracks)*age; + } + void material(inout MaterialInputs material){ + prepareMaterial(material); + vec2 p=variable_objectPos.xz; + float grain=hash(floor(p*75.0)); + vec3 color=mix(vec3(.012,.014,.017),vec3(.026,.021,.016),grain*.3); + float d=0.0; + d=max(d,impact(p,vec2(-.78,.38),.1,color)); + d=max(d,impact(p,vec2(.22,.52),.72,color)); + d=max(d,impact(p,vec2(.68,-.16),1.34,color)); + d=max(d,impact(p,vec2(-.28,-.48),1.96,color)); + float vignette=smoothstep(2.1,.4,length(p)); + color*=.62+.38*vignette; + material.baseColor=vec4(color,1.0); + } +} diff --git a/examples/assets/dissolve_burn.filamat b/examples/assets/dissolve_burn.filamat new file mode 100644 index 000000000..68a06fce6 Binary files /dev/null and b/examples/assets/dissolve_burn.filamat differ diff --git a/examples/assets/dissolve_burn.mat b/examples/assets/dissolve_burn.mat new file mode 100644 index 000000000..5dc19b85e --- /dev/null +++ b/examples/assets/dissolve_burn.mat @@ -0,0 +1,119 @@ +material { + name : DissolveBurn, + requires : [ position, tangents ], + variables : [ + objectPos, + surfaceNormal + ], + parameters : [ + { type : float4, name : baseColor }, + { type : float4, name : edgeColor }, + { type : float, name : threshold }, + { type : float, name : edgeWidth }, + { type : float, name : edgeIntensity }, + { type : float, name : noiseScale }, + { type : float, name : time } + ], + depthWrite : true, + depthCulling : true, + shadingModel : unlit, + blending : opaque, + culling : none, + variantFilter : [ shadowReceiver, vsm ] +} + +vertex { + void materialVertex(inout MaterialVertexInputs material) { + // Object-space position for the noise field: pinning the noise to + // the mesh means a moving model slides through the burn instead of + // the burn sliding across the model. + material.objectPos.xyz = getPosition().xyz; + material.surfaceNormal.xyz = material.worldNormal; + } +} + +fragment { + float hash(vec3 p) { + p = fract(p * vec3(443.897, 441.423, 437.195)); + p += dot(p, p.yzx + 19.19); + return fract((p.x + p.y) * p.z); + } + + float noise3(vec3 p) { + vec3 i = floor(p); + vec3 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix( + mix(mix(hash(i), hash(i + vec3(1, 0, 0)), f.x), + mix(hash(i + vec3(0, 1, 0)), hash(i + vec3(1, 1, 0)), f.x), f.y), + mix(mix(hash(i + vec3(0, 0, 1)), hash(i + vec3(1, 0, 1)), f.x), + mix(hash(i + vec3(0, 1, 1)), hash(i + vec3(1, 1, 1)), f.x), f.y), + f.z); + } + + float fbm(vec3 p) { + float v = 0.0; + v += 0.5 * noise3(p); p *= 2.01; + v += 0.25 * noise3(p); p *= 2.02; + v += 0.125 * noise3(p); p *= 2.00; + v += 0.0625 * noise3(p); + return v / 0.9375; + } + + void material(inout MaterialInputs material) { + prepareMaterial(material); + + vec3 pos = variable_objectPos.xyz; + float n = fbm(pos * materialParams.noiseScale + + vec3(0.0, 0.0, materialParams.time * 0.15)); + + float t = clamp(materialParams.threshold, 0.0, 1.0); + if (n < t) { + discard; + } + + // Distance from the burn front, normalized over the edge band: + // 0 right at the front, 1 once past it. + float e = clamp((n - t) / materialParams.edgeWidth, 0.0, 1.0); + float front = 1.0 - e; + + // --- burning edge: white-hot core -> orange -> deep red ---------- + vec3 hot = vec3(1.0, 0.96, 0.82); + vec3 mid = materialParams.edgeColor.rgb; + vec3 cool = vec3(0.55, 0.05, 0.0); + vec3 edgeCol = mix(hot, mid, smoothstep(0.06, 0.4, e)); + edgeCol = mix(edgeCol, cool, smoothstep(0.4, 1.0, e)); + + // The front flickers like real combustion. + float flicker = 0.78 + 0.3 * + noise3(pos * 7.0 + vec3(0.0, 0.0, materialParams.time * 5.0)) + + 0.16 * noise3(pos * 15.0 + + vec3(0.0, 0.0, materialParams.time * 9.0)); + float glow = pow(front, 2.2) * materialParams.edgeIntensity * flicker; + + // --- ember sparks right at the front ------------------------------ + float spark = step(0.988, hash(floor(pos * 47.0) + + floor(materialParams.time * 9.0))) * + pow(front, 4.0); + + // --- charred surface ---------------------------------------------- + // Near the front the material is fully charred and faintly glowing; + // further ahead it is merely scorched, then untouched. + float charr = smoothstep(t + materialParams.edgeWidth * 3.0, + t + materialParams.edgeWidth * 0.6, n); + float detail = fbm(pos * materialParams.noiseScale * 2.7 + 31.0); + vec3 normal = normalize(variable_surfaceNormal.xyz); + vec3 key = normalize(vec3(-0.35, 0.75, 0.55)); + float diffuse = 0.28 + 0.72 * max(dot(normal, key), 0.0); + float grazing = pow(1.0 - abs(normal.z), 3.0); + vec3 color = materialParams.baseColor.rgb * + (0.54 + 0.50 * detail) * diffuse; + color += materialParams.baseColor.rgb * grazing * 0.18; + color *= 1.0 - 0.72 * charr; + color += cool * pow(charr, 3.0) * 0.45; + + color += edgeCol * glow + vec3(1.0, 0.65, 0.22) * spark * 1.8; + + material.baseColor = vec4(color, 1.0); + } +} diff --git a/examples/assets/electricity.filamat b/examples/assets/electricity.filamat new file mode 100644 index 000000000..8927c84fa Binary files /dev/null and b/examples/assets/electricity.filamat differ diff --git a/examples/assets/electricity.mat b/examples/assets/electricity.mat new file mode 100644 index 000000000..4b526d416 --- /dev/null +++ b/examples/assets/electricity.mat @@ -0,0 +1,52 @@ +material { + name : Electricity, + shadingModel : unlit, + requires : [ position ], + variables : [ quadUv, boltData ], + parameters : [ { type : float, name : time }, { type : float, name : segmentCount } ], + blending : add, + depthWrite : false, + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} +vertex { + float hash(float n){return fract(sin(n)*43758.5453);} + vec2 path(float u,float seed,float t){ + float x=sin(u*11.0+seed)*.18+sin(u*27.0-seed*2.0)*.075; + x+=(hash(floor(u*28.0)+seed+floor(t*18.0))-.5)*.22; + return vec2(x,(u-.5)*2.55); + } + void materialVertex(inout MaterialVertexInputs material){ + int vi=getVertexIndex(); int corner=vi%6; int qi=vi/6; + vec2 uv=vec2(corner==1||corner==2||corner==4?1.0:0.0,corner==2||corner==3||corner==4?1.0:0.0); + float count=materialParams.segmentCount; float mainCount=floor(count*.62); + float id=float(qi); float branch=step(mainCount-.5,id); float sid=mix(id,id-mainCount,branch); + float u0=(sid+floor(branch*mod(sid,3.0)))/mainCount; + float u1=u0+1.0/mainCount; + vec2 a=path(u0,3.7,materialParams.time), b=path(u1,3.7,materialParams.time); + if(branch>.5){ + float group=floor(sid/3.0); float root=fract((group*7.0+5.0)/mainCount)*.76+.12; + float seg=mod(sid,3.0); float side=mix(-1.0,1.0,step(.5,hash(group+9.0))); + a=path(root+seg/mainCount,3.7,materialParams.time)+vec2(side*seg*.055,0.0); + b=path(root+(seg+1.0)/mainCount,3.7,materialParams.time)+vec2(side*(seg+1.0)*.09,.02); + } + vec2 d=b-a; float len=length(d); vec2 tangent=d/max(len,.001), side=vec2(-tangent.y,tangent.x); + float width=mix(.072,.042,branch); + vec2 pt=mix(a,b,uv.y)+side*(uv.x-.5)*width; + material.worldPosition.xyz+=vec3(pt.x,pt.y,0.0)-getPosition().xyz; + material.quadUv.xy=uv; material.boltData.xy=vec2(branch,id); + } +} +fragment { + float hash(float n){return fract(sin(n)*43758.5453);} + void material(inout MaterialInputs material){ + prepareMaterial(material); + vec2 uv=variable_quadUv.xy; float d=abs(uv.x-.5)*2.0; + float core=pow(max(1.0-d,0.0),11.0), glow=pow(max(1.0-d,0.0),2.0); + float pulse=.72+.28*sin(materialParams.time*32.0+variable_boltData.y*1.7); + float branch=variable_boltData.x; + vec3 color=mix(vec3(.08,.18,1.0),vec3(.42,.05,1.0),branch); + color=color*glow*.65+vec3(.65,.88,1.0)*core*4.4; + material.baseColor=vec4(color*pulse*(1.0-branch*.22),1.0); + } +} diff --git a/examples/assets/energy_weapon.filamat b/examples/assets/energy_weapon.filamat new file mode 100644 index 000000000..cb51d4b10 Binary files /dev/null and b/examples/assets/energy_weapon.filamat differ diff --git a/examples/assets/energy_weapon.mat b/examples/assets/energy_weapon.mat new file mode 100644 index 000000000..aa4a8238a --- /dev/null +++ b/examples/assets/energy_weapon.mat @@ -0,0 +1,67 @@ +material { + name : EnergyWeapon, + shadingModel : unlit, + requires : [ position ], + variables : [ objectPos ], + parameters : [ + { type : float, name : time }, + { type : float, name : mode }, + { type : float, name : phase } + ], + blending : add, + depthWrite : false, + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} +vertex { void materialVertex(inout MaterialVertexInputs material){material.objectPos.xyz=getPosition().xyz;} } +fragment { + float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);} + float noise2(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.0-2.0*f);return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);} + void material(inout MaterialInputs material){ + prepareMaterial(material); + vec2 p=variable_objectPos.xz; float mode=materialParams.mode,t=materialParams.time; + vec3 color=vec3(0.0); float amount=0.0; + if(mode<.5){ + float x=p.x/4.7+.5, y=abs(p.y)/.22; + float envelope=smoothstep(0.0,.08,x)*(1.0-smoothstep(.84,1.0,x)); + float core=exp(-y*y*19.0), halo=exp(-y*y*2.2); + float plasma=.6+.4*noise2(vec2(x*35.0-t*9.0,p.y*22.0)); + float pulse=pow(.5+.5*sin(x*70.0-t*18.0),8.0); + float beamAmount=envelope*(core*1.15+halo*.44*plasma+pulse*core*.65)*materialParams.phase; + vec3 beamColor=mix(vec3(.015,.1,.82),vec3(.38,.82,1.0),core); + + float cycle=mod(t,2.6); + float charge=clamp(cycle/.65,0.0,1.0); + float hit=clamp((cycle-.92)/.95,0.0,1.0); + float hitEnvelope=sin(hit*3.14159265); + vec2 muzzleP=p-vec2(-1.55,0.0); + float muzzleR=length(muzzleP); + float muzzle=exp(-muzzleR*muzzleR*24.0)*(0.12+charge*.95)*(1.0-materialParams.phase*.55); + muzzle+=exp(-abs(muzzleR-(.08+charge*.2))*40.0)*charge*.65; + + vec2 impactP=p-vec2(1.55,0.0); + float impactR=length(impactP), impactA=atan(impactP.y,impactP.x); + float impact=exp(-impactR*impactR*19.0)*hitEnvelope*1.8; + impact+=exp(-abs(impactR-hit*.48)*32.0)*hitEnvelope*1.5; + impact+=pow(max(sin(impactA*10.0+t*7.0),0.0),18.0)* + smoothstep(.56,.05,impactR)*hitEnvelope*.9; + + color=beamColor*beamAmount+ + vec3(.08,.48,1.0)*muzzle+ + vec3(.42,.08,1.0)*impact; + color+=vec3(.62,.9,1.0)*(core*beamAmount*.45+ + exp(-muzzleR*muzzleR*85.0)*charge*.8+ + exp(-impactR*impactR*70.0)*hitEnvelope); + amount=1.0; + } else { + vec2 q=p*2.0; float r=length(q),a=atan(q.y,q.x); + if(r>.94) discard; + float ring=exp(-abs(r-materialParams.phase*.72)*22.0); + float rays=pow(max(0.0,sin(a*11.0+t*8.0)),16.0)*smoothstep(.85,.04,r); + float orb=pow(max(1.0-r,0.0),2.2); + amount=(orb*4.5+ring*4.0+rays*2.4)*smoothstep(0.0,.04,materialParams.phase)*(1.0-smoothstep(.88,1.0,materialParams.phase)); + color=mix(vec3(.055,.008,.62),vec3(.28,.72,1.0),clamp(orb+ring,0.0,1.0)); + } + material.baseColor=vec4(color*amount*.055,1.0); + } +} diff --git a/examples/assets/fire.filamat b/examples/assets/fire.filamat new file mode 100644 index 000000000..6dd3445de Binary files /dev/null and b/examples/assets/fire.filamat differ diff --git a/examples/assets/fire.mat b/examples/assets/fire.mat new file mode 100644 index 000000000..1e9ce1e7b --- /dev/null +++ b/examples/assets/fire.mat @@ -0,0 +1,215 @@ +material { + name : Fire, + variables : [ + quadData + ], + parameters : [ + { type : float, name : time }, + { type : float, name : flameCount }, + { type : float, name : emberCount }, + { type : float, name : flameHeight }, + { type : float, name : flameWidth }, + { type : float, name : noiseScale }, + { type : float, name : scrollSpeed }, + { type : float, name : windLean }, + { type : float, name : emberLifetime } + ], + depthWrite : false, + depthCulling : false, + shadingModel : unlit, + blending : add, + culling : none, + vertexDomain : object +} + +vertex { + void materialVertex(inout MaterialVertexInputs material) { + // One quad per 6 vertices, generated entirely in the vertex shader + // (see smoke.mat). Quads [0, flameCount) are flame tongues; the + // rest are ember sparks - a single draw call for the whole fire. + int vid = getVertexIndex(); + int quadIndex = vid / 6; + int vertInQuad = vid - quadIndex * 6; + + float seed = float(quadIndex); + float rng1 = fract(sin(seed * 127.1) * 43758.5453); + float rng2 = fract(sin(seed * 269.5) * 43758.5453); + float rng3 = fract(sin(seed * 419.2) * 43758.5453); + float rng4 = fract(sin(seed * 113.5) * 43758.5453); + + // Corner table maps y to [0, 1] so the quad stands ON its center + // (flames rise from the base, not through it). + vec2 positions[6] = vec2[6]( + vec2(-0.5, 0.0), vec2( 0.5, 0.0), vec2(-0.5, 1.0), + vec2( 0.5, 0.0), vec2( 0.5, 1.0), vec2(-0.5, 1.0) + ); + vec2 uvs[6] = vec2[6]( + vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0), + vec2(1.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 1.0) + ); + vec2 corner = positions[vertInQuad]; + vec2 uv = uvs[vertInQuad]; + + mat4 view = getViewFromWorldMatrix(); + vec3 camRight = vec3(view[0].x, view[1].x, view[2].x); + vec3 camUp = vec3(view[0].y, view[1].y, view[2].y); + + float t = materialParams.time; + vec3 center; + vec2 p; + // quadData: (uv, flame: flicker brightness / ember: fade, 0 flame + // / 1 ember) + vec4 qd; + + if (quadIndex < int(materialParams.flameCount)) { + // --- flame tongue -------------------------------------------- + // Two rings of tongues - a tight hot core and a looser skirt - + // each with its own height and flicker phase so tips never move + // in lockstep, plus a slow wander of the cluster so the fire + // breathes rather than flickering in place. + float mainBody = 1.0 - step(0.5, seed); + float ring = step(6.0, float(quadIndex)); + float clusterR = mix(0.018, 0.085, ring) * + (0.8 + 0.4 * rng2) * (1.0 - mainBody); + float ang = seed * 2.399; + float h = materialParams.flameHeight * (0.55 + 0.58 * rng4) * + mix(1.10, 0.72, ring) * + (1.0 + 0.14 * sin(t * 6.5 + seed * 2.7) + + 0.08 * sin(t * 11.0 + seed)); + float w = materialParams.flameWidth * (0.85 + 0.3 * rng2) * + mix(1.22, 1.38, ring); + h = mix(h, materialParams.flameHeight * 1.18, mainBody); + w = mix(w, materialParams.flameWidth * 3.25, mainBody); + + center = vec3(cos(ang) * clusterR + 0.05 * sin(t * 0.9 + seed), + 0.0, + sin(ang) * clusterR * 0.7); + p = vec2(corner.x * w, corner.y * h); + // Wind shear grows with height and sways slowly. + p.x += p.y * materialParams.windLean * + (0.7 + 0.3 * sin(t * 2.5 + seed)); + + qd = vec4(uv, (0.85 + 0.15 * sin(t * 11.0 + seed * 5.0)) * + mix(1.0, 1.18, mainBody), mainBody * 0.25); + } else { + // --- ember spark ---------------------------------------------- + float e = float(quadIndex - int(materialParams.flameCount)); + float s2 = fract(sin(e * 173.3) * 43758.5453); + float s3 = fract(sin(e * 311.7) * 43758.5453); + float s4 = fract(sin(e * 101.9) * 43758.5453); + float et = mod(t - s2 * materialParams.emberLifetime, + materialParams.emberLifetime); + float eage = et / materialParams.emberLifetime; + + // Rise from inside the flame, wobbling, stretching into little + // streaks as they climb. + center = vec3((s3 - 0.5) * 0.2 + sin(et * 9.0 + s2 * 6.28) * 0.09 * eage, + 0.25 + et * 1.35 * (0.7 + s4 * 0.6), + (s4 - 0.5) * 0.2); + float sz = (0.04 + 0.05 * s3) * (1.0 - eage * 0.7) * + (s3 > 0.9 ? 1.9 : 1.0); + p = vec2(corner.x * sz, corner.y * sz * (1.0 + eage * 1.5)); + + qd = vec4(uv, 1.0 - eage, 1.0); + } + + // Additive displacement only - see the NOTE in smoke.mat. + material.worldPosition.xyz += center + camRight * p.x + camUp * p.y; + material.quadData = qd; + } +} + +fragment { + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + float noise2(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), + mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); + } + + float fbm(vec2 p) { + float v = 0.0; + v += 0.5 * noise2(p); p *= 2.03; + v += 0.25 * noise2(p); p *= 2.01; + v += 0.125 * noise2(p); + return v / 0.875; + } + + void material(inout MaterialInputs material) { + prepareMaterial(material); + + vec2 uv = variable_quadData.xy; + float t = materialParams.time; + + if (variable_quadData.w < 0.5) { + // --- flame: scrolling warped noise + blackbody ramp ---------- + // The noise scrolls DOWN in noise-space (so its features read + // as licking upward) and is squashed vertically so licks come + // out tall and narrow. + vec2 q = uv * vec2(materialParams.noiseScale, + materialParams.noiseScale * 0.62) + + vec2(0.0, -t * materialParams.scrollSpeed); + float warp = fbm(q + vec2(t * 0.15, 0.0)); + float f = fbm(q + warp * 1.9); + + // Sinuous center line and height-dependent taper hide the quad + // silhouette. Noise erodes the sides and forks the upper licks. + float center = 0.5 + (f - 0.5) * (0.18 + uv.y * 0.22) + + 0.055 * sin(uv.y * 8.0 + t * 4.0); + float mainBody = step(0.1, variable_quadData.w); + float bell = pow(max(sin(uv.y * 3.14159265), 0.0), 0.48); + float width = mix(0.055, 0.44, bell) * + mix(1.0, 0.42, pow(uv.y, 1.6)); + width = mix(width, max(width, 0.34 * (1.0 - uv.y * 0.55)), + mainBody); + float side = 1.0 - smoothstep(width * 0.48, width, + abs(uv.x - center)); + float tip = 1.0 - smoothstep(0.56, 1.02, + uv.y + (0.56 - f) * 0.72); + float tongues = side * tip * smoothstep(mix(0.26, 0.16, mainBody), + mix(0.58, 0.46, mainBody), + f + side * 0.34) * + smoothstep(0.0, 0.055, uv.y); + float mask = tongues; + float heat = mask * (1.48 - uv.y * 0.92) * variable_quadData.z; + + // Blackbody-ish ramp: deep red -> orange -> yellow -> white. + vec3 col = mix(vec3(0.62, 0.002, 0.0), vec3(1.0, 0.055, 0.001), + smoothstep(0.05, 0.32, heat)); + col = mix(col, vec3(1.0, 0.16, 0.005), + smoothstep(0.32, 0.65, heat)); + col = mix(col, vec3(1.0, 0.34, 0.025), + smoothstep(0.65, 1.0, heat)); + // Saturated red where the heat has died at the tips. + col = mix(col, vec3(0.72, 0.012, 0.0), + smoothstep(0.35, 0.05, heat) * uv.y * 0.9); + // A whisper of blue where the coldest fuel enters at the base. + col = mix(col, vec3(0.45, 0.55, 1.0), + (1.0 - smoothstep(0.0, 0.1, uv.y)) * 0.35); + + float amount = smoothstep(0.025, 0.32, heat) * + clamp(heat * 0.34, 0.0, 0.34); + if (heat < 0.028) { + discard; + } + // Premultiplied transparency preserves the blackbody hues; + // additive overlap was tonemapping every tongue toward tan. + material.baseColor = vec4(col * amount * 0.075, 1.0); + } else { + // --- ember spark: hot streak fading red ------------------------ + float fade = variable_quadData.z; + float flick = 0.85 + 0.3 * sin(t * 17.0 + fade * 31.0); + float d = length((uv - 0.5) * vec2(1.0, 0.6)) * 2.0; + float dot_ = 1.0 - smoothstep(0.05, 0.55, d); + vec3 col = mix(vec3(1.0, 0.22, 0.02), vec3(1.0, 0.9, 0.65), + smoothstep(0.3, 1.0, fade)); + float amount = dot_ * dot_ * fade * flick * 0.78; + material.baseColor = vec4(col * amount * 0.070, 1.0); + } + } +} diff --git a/examples/assets/fire_ground.filamat b/examples/assets/fire_ground.filamat new file mode 100644 index 000000000..e886116e0 Binary files /dev/null and b/examples/assets/fire_ground.filamat differ diff --git a/examples/assets/fire_ground.mat b/examples/assets/fire_ground.mat new file mode 100644 index 000000000..fcfec57de --- /dev/null +++ b/examples/assets/fire_ground.mat @@ -0,0 +1,44 @@ +material { + name : FireGround, + requires : [ position ], + variables : [ objectPos ], + parameters : [ { type : float, name : time } ], + depthWrite : false, + depthCulling : false, + shadingModel : unlit, + blending : add, + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} + +vertex { + void materialVertex(inout MaterialVertexInputs material) { + material.objectPos.xyz = getPosition().xyz; + } +} + +fragment { + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + void material(inout MaterialInputs material) { + prepareMaterial(material); + vec2 p = variable_objectPos.xz * 2.0; + float r = length(p); + if (r > 1.0) { + discard; + } + float a = atan(p.y, p.x); + float pulse = 0.82 + 0.18 * sin(materialParams.time * 4.3); + float radial = pow(max(1.0 - r, 0.0), 2.2); + float coal = step(0.77, hash(floor(p * 13.0))) * + smoothstep(0.95, 0.15, r); + float cracks = pow(abs(sin(a * 7.0 + r * 18.0)), 22.0) * + smoothstep(0.95, 0.12, r); + vec3 color = mix(vec3(0.95, 0.025, 0.0), + vec3(1.0, 0.32, 0.015), radial); + float amount = (radial * 0.72 + coal * 0.55 + cracks * 0.24) * pulse; + material.baseColor = vec4(color * amount * 0.032, 1.0); + } +} diff --git a/examples/assets/force_core.filamat b/examples/assets/force_core.filamat new file mode 100644 index 000000000..95c2e42da Binary files /dev/null and b/examples/assets/force_core.filamat differ diff --git a/examples/assets/force_core.mat b/examples/assets/force_core.mat new file mode 100644 index 000000000..7e4d0f144 --- /dev/null +++ b/examples/assets/force_core.mat @@ -0,0 +1,40 @@ +material { + name : ForceCore, + requires : [ position ], + variables : [ objectPos ], + parameters : [ + { type : float4, name : baseColor }, + { type : float, name : time } + ], + depthWrite : true, + depthCulling : true, + shadingModel : unlit, + blending : opaque, + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} + +vertex { + void materialVertex(inout MaterialVertexInputs material) { + material.objectPos.xyz = getPosition().xyz; + } +} + +fragment { + void material(inout MaterialInputs material) { + prepareMaterial(material); + vec3 p = variable_objectPos.xyz; + float edge = max(max(abs(p.x), abs(p.y)), abs(p.z)) * 2.0; + float shell = smoothstep(0.38, 0.98, edge); + float scan = 0.5 + 0.5 * sin((p.y + p.x * 0.34) * 34.0 - + materialParams.time * 6.0); + float pulse = 0.72 + 0.28 * sin(materialParams.time * 3.2); + vec3 deep = materialParams.baseColor.rgb * 0.055; + vec3 energy = materialParams.baseColor.rgb * (0.45 + scan * 0.48); + vec3 color = mix(deep, energy, shell) * pulse; + color += vec3(0.32, 0.72, 1.0) * pow(scan, 9.0) * shell * 0.22; + // Calibrated for the headless HDR exposure; without this reduction + // the core clips to a featureless white block. + material.baseColor = vec4(color * 0.052, 1.0); + } +} diff --git a/examples/assets/force_field.filamat b/examples/assets/force_field.filamat new file mode 100644 index 000000000..55bd72e16 Binary files /dev/null and b/examples/assets/force_field.filamat differ diff --git a/examples/assets/force_field.mat b/examples/assets/force_field.mat new file mode 100644 index 000000000..861e0ab14 --- /dev/null +++ b/examples/assets/force_field.mat @@ -0,0 +1,106 @@ +material { + name : ForceField, + requires : [ position, tangents ], + variables : [ + surfaceNormal, + worldPos + ], + parameters : [ + { type : float4, name : baseColor }, + { type : float, name : time }, + { type : float, name : fresnelPower }, + { type : float, name : hexScale }, + { type : float, name : hexStrength }, + { type : float3, name : hitDirection }, + { type : float, name : hitAge } + ], + depthWrite : false, + depthCulling : false, + shadingModel : unlit, + blending : add, + culling : back, + variantFilter : [ skinning, shadowReceiver, vsm ] +} + +fragment { + void material(inout MaterialInputs material) { + prepareMaterial(material); + + vec3 normal = normalize(variable_surfaceNormal.xyz); + vec3 viewDir = normalize(getWorldCameraPosition() - variable_worldPos.xyz); + float t = materialParams.time; + + // Fresnel rim gives the bubble its silhouette, with a hotter thin + // lip right at the edge. + float ndv = abs(dot(normal, viewDir)); + float fresnel = pow(1.0 - ndv, materialParams.fresnelPower); + float rimLip = pow(1.0 - ndv, 8.0); + + // --- curved energy lattice --------------------------------------- + // Two families of thin, gently warped spherical arcs read as an + // engineered field without revealing the source mesh's triangles. + float lon = atan(normal.z, normal.x); + float lat = asin(clamp(normal.y, -1.0, 1.0)); + float cells = materialParams.hexScale * 0.5; + float warpA = sin(lat * 5.0 + t * 0.35) * 0.24; + float warpB = sin(lon * 3.0 - t * 0.28) * 0.16; + float arcA = pow(abs(sin(lon * cells + warpA)), 24.0); + float arcB = pow(abs(sin(lat * cells * 1.72 + warpB)), 28.0); + float poleFade = smoothstep(0.995, 0.87, abs(normal.y)); + float line = max(arcA * poleFade, arcB); + float node = arcA * arcB * poleFade; + + // Directional power flow travels through the web while intersections + // ignite as small nodes. + float phase = lon * 5.0 + lat * 9.0 - t * 3.8; + float flow = 0.58 + 0.42 * sin(phase); + float secondary = 0.72 + 0.28 * sin(phase * 0.47 + t * 1.7); + float lattice = (line * flow * secondary + node * 0.7) * + materialParams.hexStrength; + + // --- impact response ---------------------------------------------- + // A bright splash at the impact site, then a sharp ring expanding + // over the sphere's angular distance with a trailing echo. The ring + // flares the lattice as it crosses it. + float ang = acos(clamp(dot(normal, normalize(materialParams.hitDirection)), + -1.0, 1.0)); + float age = materialParams.hitAge; + float da = ang * 7.5; + float splash = exp(-da * da) * exp(-age * 8.5) * 2.2; + float ringR = age * 2.05; + float dr_8171 = ((ang - ringR) * 12.5); + float ring = exp(-dr_8171 * dr_8171) * exp(-age * 0.75); + float dr_1736 = ((ang - ringR * 0.73) * 9.0); + float echo = 0.42 * exp(-dr_1736 * dr_1736) * exp(-age * 0.9); + vec3 hitDir = normalize(materialParams.hitDirection); + vec3 ringU = normalize(cross(hitDir, vec3(0.0, 1.0, 0.0))); + vec3 ringV = cross(hitDir, ringU); + float ringAngle = atan(dot(normal, ringV), dot(normal, ringU)); + float arcSparks = ring * pow(max(sin(ringAngle * 9.0 - t * 7.0), + 0.0), 12.0) * 2.4; + float ripple = splash + ring + echo + arcSparks; + + float amount = 0.012 + fresnel * 0.56 + rimLip * 1.35 + + lattice * (0.92 + ripple * 4.2) + ripple * 5.2; + + vec3 color = materialParams.baseColor.rgb; + color = mix(color, vec3(0.85, 0.95, 1.0), + clamp(ripple * 0.8, 0.0, 0.8)); + color *= 0.85 + 0.3 * fresnel; + + // Additive materials pass through the engine's HDR exposure before + // tonemapping (~300x on this pipeline), so artistic 0..4 values are + // scaled down here: 0.5 reads as a mid glow, 2+ as near-white. + material.baseColor = vec4(color * amount * 0.0052, 1.0); + } +} + +vertex { + void materialVertex(inout MaterialVertexInputs material) { + material.surfaceNormal.xyz = material.worldNormal; + // material.worldPosition is pre-transform on this pipeline; build + // true world position from the model matrix. + material.worldPos.xyz = (getWorldFromModelMatrix() * + vec4(getPosition().xyz, 1.0)).xyz; + } +} diff --git a/examples/assets/hit_flash.filamat b/examples/assets/hit_flash.filamat new file mode 100644 index 000000000..44f6e7c5d Binary files /dev/null and b/examples/assets/hit_flash.filamat differ diff --git a/examples/assets/hit_flash.mat b/examples/assets/hit_flash.mat new file mode 100644 index 000000000..5b5d86d6a --- /dev/null +++ b/examples/assets/hit_flash.mat @@ -0,0 +1,99 @@ +material { + name : HitFlash, + requires : [ position, tangents ], + variables : [ + surfaceNormal, + worldPos + ], + parameters : [ + { type : float4, name : flashColor }, + { type : float3, name : hitPoint }, + { type : float, name : progress }, + { type : float, name : normalOffset } + ], + depthWrite : false, + depthCulling : true, + shadingModel : unlit, + blending : add, + transparency : twoPassesOneSide, + culling : back +} + +vertex { + void materialVertex(inout MaterialVertexInputs material) { + material.surfaceNormal.xyz = material.worldNormal; + // Lift the effect skin a few millimeters along the source surface so + // it depth-tests cleanly over the original PBR mesh. + material.worldPosition.xyz += material.worldNormal * + materialParams.normalOffset; + // getWorldPosition() in the fragment stage returns view-relative + // coordinates on this pipeline - pipe true world position through a + // variable instead (as water/lava do). + // material.worldPosition is pre-transform on this pipeline; build + // true world position from the model matrix. + material.worldPos.xyz = (getWorldFromModelMatrix() * + vec4(getPosition().xyz, 1.0)).xyz; + } +} + +fragment { + void material(inout MaterialInputs material) { + prepareMaterial(material); + + float p = clamp(materialParams.progress, 0.0, 1.0); + // Fast-decaying envelope: full flash at the instant of impact. + float t = 1.0 - p; + float intensity = pow(t, 3.0); + + vec3 normal = normalize(variable_surfaceNormal.xyz); + vec3 pos = variable_worldPos.xyz; + vec3 viewDir = normalize(getWorldCameraPosition() - pos); + float rim = pow(1.0 - abs(dot(normal, viewDir)), 2.0); + float r = distance(pos, materialParams.hitPoint); + + // Localized white-hot spot at the impact point: dies fast, so the + // first frames read unmistakably as "hit HERE". + float dr_1939 = (r * 1.9); + float hotspot = exp(-dr_1939 * dr_1939) * pow(t, 1.2) * 2.6; + + // Body flash: mostly silhouette-weighted, only a whisper of fill so + // the mesh stays readable through it. + float body = intensity * (0.06 + 0.72 * pow(rim, 1.6)) + + hotspot * (0.3 + 0.6 * rim); + + // Shockwave ring crossing the mesh surface: a sharp leading edge + // with a tight warm wake just behind it. The radius tops out around + // the mesh's own extent so the ring spends the whole flash sweep + // ON the surface instead of leaving it early. + // The impact point sits just inside the surface, so distances to + // the mesh span roughly 0.25 (near side) to 1.8 (far side); the + // ring sweeps that whole range. + float ringR = 0.25 + pow(p, 0.75) * 1.15; + float w = 15.0 / (1.0 + (ringR - 0.25) * 1.4); + float dr_8550 = ((r - ringR) * w); + float ring = exp(-dr_8550 * dr_8550); + float dr_5535 = ((r - ringR * 0.8) * w * 0.7); + float wake = exp(-dr_5535 * dr_5535); + float env = (0.45 + 0.85 * min(p * 3.0, 1.0)) * + (1.0 - smoothstep(0.72, 1.0, p)); + float ringTotal = (ring * 0.8 + wake * 0.3) * env; + + // White-hot at the impact instant settling into the flash color. + // The ring runs a more saturated version of the flash color so the + // sweep reads two-tone against the white-hot core. + vec3 flash = mix(vec3(1.0, 0.98, 0.92), materialParams.flashColor.rgb, + smoothstep(0.0, 0.55, p)); + vec3 ringCol = mix(vec3(1.0, 0.99, 0.95), + materialParams.flashColor.rgb * + vec3(2.4, 0.75, 0.3), + smoothstep(0.0, 0.35, p)); + + vec3 color = flash * body + ringCol * ringTotal; + + // Additive materials pass through the engine's HDR exposure before + // tonemapping (~300x on this pipeline), so artistic 0..4 values are + // scaled down here: 0.5 reads as a mid glow, 2+ as white-hot + // (calibrated against the headless capture pipeline). + material.baseColor = vec4(color * 0.12, 1.0); + } +} diff --git a/examples/assets/hologram.filamat b/examples/assets/hologram.filamat new file mode 100644 index 000000000..04dc544a3 Binary files /dev/null and b/examples/assets/hologram.filamat differ diff --git a/examples/assets/hologram.mat b/examples/assets/hologram.mat new file mode 100644 index 000000000..0dcbaf96c --- /dev/null +++ b/examples/assets/hologram.mat @@ -0,0 +1,135 @@ +material { + name : Hologram, + requires : [ position, tangents ], + variables : [ + surfaceNormal, + worldPos + ], + parameters : [ + { type : float4, name : tintColor }, + { type : float, name : time }, + { type : float, name : fresnelPower }, + { type : float, name : fresnelStrength }, + { type : float, name : scanlineCount }, + { type : float, name : scanlineSpeed }, + { type : float, name : glitchAmount } + ], + depthWrite : false, + depthCulling : false, + shadingModel : unlit, + blending : transparent, + culling : none, + variantFilter : [ shadowReceiver, vsm ] +} + +vertex { + float hash1(float n) { + return fract(sin(n * 91.17) * 43758.5453); + } + + void materialVertex(inout MaterialVertexInputs material) { + material.surfaceNormal.xyz = material.worldNormal; + // material.worldPosition is pre-transform on this pipeline; build + // true world position from the model matrix. + material.worldPos.xyz = (getWorldFromModelMatrix() * + vec4(getPosition().xyz, 1.0)).xyz; + + // Glitch gating, shared verbatim with the fragment stage: roughly a + // third of the time slices (2.5/s) shear horizontal bands. Two + // bands tear in different directions for a busier interference + // read. + float t = materialParams.time; + float k = floor(t * 2.5); + float gate = hash1(k); + float glitchOn = step(0.72, gate); + float band1 = glitchOn * + (1.0 - smoothstep(0.0, 0.07, abs(material.worldPosition.y - + (hash1(k + 7.0) - 0.5) * 1.1))); + float band2 = glitchOn * + (1.0 - smoothstep(0.0, 0.05, abs(material.worldPosition.y - + (hash1(k + 29.0) - 0.5) * 1.1))); + material.worldPosition.x += + band1 * materialParams.glitchAmount * hash1(k + 13.0) * 2.0; + material.worldPosition.x -= + band2 * materialParams.glitchAmount * 0.6 * hash1(k + 41.0) * 2.0; + } +} + +fragment { + float hash1(float n) { + return fract(sin(n * 91.17) * 43758.5453); + } + + void material(inout MaterialInputs material) { + prepareMaterial(material); + + vec3 pos = variable_worldPos.xyz; + vec3 normal = normalize(variable_surfaceNormal.xyz); + vec3 viewDir = normalize(getWorldCameraPosition() - pos); + float t = materialParams.time; + + // Fresnel rim: edges read brighter, giving the shell-like hologram + // look. Kept saturated - brighten via alpha, not by mixing to white. + float fresnel = pow(1.0 - abs(dot(normal, viewDir)), + materialParams.fresnelPower); + fresnel *= materialParams.fresnelStrength; + + // Two layers of fine horizontal scanlines sweeping upward: a dense + // fine set plus a slower coarse set, so the surface shimmers rather + // than showing one static frequency. + float scanA = smoothstep(0.42, 0.96, + sin(pos.y * materialParams.scanlineCount - + t * materialParams.scanlineSpeed)) * 0.22; + float scanB = smoothstep(0.55, 1.0, + sin(pos.y * materialParams.scanlineCount * 0.37 - + t * materialParams.scanlineSpeed * 0.53)) * 0.14; + float scanline = scanA + scanB; + + // The scanning band: a bright core with a soft haze around it and a + // sharp trailing edge, sweeping up the projection every ~4.8s. + float bandPos = fract(t * 0.21) * 1.7 - 0.85; + float dr_2845 = ((pos.y - bandPos) * 13.0); + float sweepCore = exp(-dr_2845 * dr_2845); + float dr_260 = ((pos.y - bandPos) * 3.2); + float sweepHaze = exp(-dr_260 * dr_260) * 0.22; + float dr_3266 = ((pos.y - bandPos - 0.055) * 42.0); + float sweepBar = exp(-dr_3266 * dr_3266) * 0.5; + float sweep = sweepCore + sweepHaze + sweepBar; + + // Glitch (same gating as the vertex shear): brightness pop plus + // complementary chromatic splits inside the two sheared bands. + float k = floor(t * 2.5); + float gate = hash1(k); + float glitchOn = step(0.72, gate); + float glitch1 = glitchOn * + (1.0 - smoothstep(0.0, 0.07, abs(pos.y - (hash1(k + 7.0) - 0.5) * 1.1))); + float glitch2 = glitchOn * + (1.0 - smoothstep(0.0, 0.05, abs(pos.y - (hash1(k + 29.0) - 0.5) * 1.1))); + + // Instability: fast shimmer, a slow brownout drift, and rare + // single-frame dropouts. + float flicker = 0.90 + 0.06 * sin(t * 47.0) + 0.04 * sin(t * 8.3); + flicker -= 0.5 * step(0.986, hash1(floor(t * 24.0))); + + // Interior floor, brighter toward the top so the body reads as + // projected light rather than empty space. + float shell = 0.045 + 0.055 * smoothstep(-0.6, 0.6, pos.y); + + float alpha = shell + fresnel * 0.54 + scanline + + sweep * 0.58 + (glitch1 + glitch2) * 0.58; + alpha = clamp(alpha, 0.0, 0.82) * max(flicker, 0.0) * + materialParams.tintColor.a; + + // Slight hue journey up the projection: deeper blue at the base, + // near-white cyan at the crown. + vec3 color = materialParams.tintColor.rgb * + (0.55 + 0.65 * fresnel + 0.6 * sweep); + color = mix(color, color * vec3(0.8, 0.95, 1.25), + smoothstep(0.2, -0.6, pos.y) * 0.5); + color += vec3(0.18, 0.34, 0.42) * sweep * 0.45; + color = mix(color, color * vec3(0.55, 0.85, 1.5), glitch1); + color = mix(color, color * vec3(1.5, 0.85, 0.55), glitch2); + + material.baseColor = vec4(color * alpha, alpha); + } +} diff --git a/examples/assets/hologram_projector.filamat b/examples/assets/hologram_projector.filamat new file mode 100644 index 000000000..6f7a5b65f Binary files /dev/null and b/examples/assets/hologram_projector.filamat differ diff --git a/examples/assets/hologram_projector.mat b/examples/assets/hologram_projector.mat new file mode 100644 index 000000000..a5b3a98a0 --- /dev/null +++ b/examples/assets/hologram_projector.mat @@ -0,0 +1,43 @@ +material { + name : HologramProjector, + requires : [ position ], + variables : [ objectPos ], + parameters : [ + { type : float4, name : tintColor }, + { type : float, name : time } + ], + depthWrite : false, + depthCulling : false, + shadingModel : unlit, + blending : add, + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} + +vertex { + void materialVertex(inout MaterialVertexInputs material) { + material.objectPos.xyz = getPosition().xyz; + } +} + +fragment { + void material(inout MaterialInputs material) { + prepareMaterial(material); + vec2 p = variable_objectPos.xz * 2.0; + float r = length(p); + if (r > 1.0) { + discard; + } + float a = atan(p.y, p.x); + float rings = pow(abs(sin(r * 28.0 - materialParams.time * 2.6)), 18.0); + float spokes = pow(abs(sin(a * 12.0 + r * 7.0)), 28.0); + float sweep = pow(max(cos(a - materialParams.time * 1.3), 0.0), 32.0); + float rim = smoothstep(0.78, 0.98, r) * smoothstep(1.0, 0.94, r); + float falloff = smoothstep(1.0, 0.05, r); + float amount = (rings * 0.42 + spokes * 0.18 + sweep * 0.85 + + rim * 1.3 + 0.025) * falloff; + vec3 color = mix(materialParams.tintColor.rgb, + vec3(0.72, 0.95, 1.0), sweep * 0.75); + material.baseColor = vec4(color * amount * 0.0065, 1.0); + } +} diff --git a/examples/assets/invisibility_cloak.filamat b/examples/assets/invisibility_cloak.filamat new file mode 100644 index 000000000..8b7f0e083 Binary files /dev/null and b/examples/assets/invisibility_cloak.filamat differ diff --git a/examples/assets/invisibility_cloak.mat b/examples/assets/invisibility_cloak.mat new file mode 100644 index 000000000..f29fda9c3 --- /dev/null +++ b/examples/assets/invisibility_cloak.mat @@ -0,0 +1,36 @@ +material { + name : InvisibilityCloak, + shadingModel : unlit, + requires : [ position, tangents ], + variables : [ objectPos, surfaceNormal, worldPos ], + parameters : [ { type : float, name : time }, { type : float, name : disruption } ], + blending : transparent, + depthWrite : false, + culling : none, + variantFilter : [ shadowReceiver, vsm ] +} +vertex { + void materialVertex(inout MaterialVertexInputs material){ + material.objectPos.xyz=getPosition().xyz; material.surfaceNormal.xyz=material.worldNormal; material.worldPos.xyz=material.worldPosition.xyz; + float shimmer=sin(getPosition().y*23.0+materialParams.time*5.0)*.006*materialParams.disruption; + material.worldPosition.xyz+=material.worldNormal*shimmer; + } +} +fragment { + float hash(vec3 p){return fract(sin(dot(p,vec3(127.1,311.7,74.7)))*43758.5453);} + float noise3(vec3 p){vec3 i=floor(p),f=fract(p);f=f*f*(3.0-2.0*f);return mix(mix(mix(hash(i),hash(i+vec3(1,0,0)),f.x),mix(hash(i+vec3(0,1,0)),hash(i+vec3(1,1,0)),f.x),f.y),mix(mix(hash(i+vec3(0,0,1)),hash(i+vec3(1,0,1)),f.x),mix(hash(i+vec3(0,1,1)),hash(i+vec3(1,1,1)),f.x),f.y),f.z);} + void material(inout MaterialInputs material){ + prepareMaterial(material); + vec3 n=normalize(variable_surfaceNormal.xyz),v=normalize(getWorldCameraPosition()-variable_worldPos.xyz),p=variable_objectPos.xyz; + float fres=pow(1.0-abs(dot(n,v)),2.15); + float band=pow(max(.0,1.0-abs(fract(p.y*3.2-materialParams.time*.75)-.5)*7.0),3.0); + float cells=smoothstep(.68,.88,noise3(p*18.0+materialParams.time*.3)); + float pulse=.5+.5*sin(materialParams.time*2.7+p.y*8.0); + float fault=(band*.8+cells*.42)*materialParams.disruption; + vec3 cyan=vec3(.03,.62,1.0),violet=vec3(.62,.08,1.0); + vec3 color=mix(cyan,violet,.5+.5*dot(n,vec3(.7,.2,.68)))*(fres*.46+fault*.38); + color+=vec3(.75,.92,1.0)*pow(fres,6.0)*.52; + float alpha=clamp(.006+fres*.18+fault*.13+pulse*.006,0.0,.32); + material.baseColor=vec4(color*alpha,alpha); + } +} diff --git a/examples/assets/lava.filamat b/examples/assets/lava.filamat new file mode 100644 index 000000000..c66c830c4 Binary files /dev/null and b/examples/assets/lava.filamat differ diff --git a/examples/assets/lava.mat b/examples/assets/lava.mat new file mode 100644 index 000000000..ab29d7477 --- /dev/null +++ b/examples/assets/lava.mat @@ -0,0 +1,137 @@ +material { + name : Lava, + requires : [ position, tangents ], + variables : [ + surfaceNormal, + surfacePos + ], + parameters : [ + { type : float, name : time }, + { type : float, name : glowIntensity }, + { type : float, name : crustScale }, + { type : float, name : flowSpeed }, + { type : float, name : swellHeight } + ], + depthWrite : true, + depthCulling : true, + shadingModel : unlit, + blending : opaque, + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} + +vertex { + // Slow crust lumps: three drifting sine terms at decreasing scale. + float crust(vec2 p, float t, float h) { + return h * (sin(p.x * 1.7 + t * 0.11) * cos(p.y * 1.3 - t * 0.09) + + 0.55 * sin(p.x * 3.1 - p.y * 2.3 + t * 0.20) + + 0.30 * sin(p.x * 5.3 + p.y * 4.1 - t * 0.30)); + } + + void materialVertex(inout MaterialVertexInputs material) { + vec2 xz = material.worldPosition.xz; + float t = materialParams.time; + float h = materialParams.swellHeight; + float y0 = crust(xz, t, h); + + // Additive displacement only - see the NOTE in smoke.mat. + material.worldPosition.xyz += vec3(0.0, y0, 0.0); + + float eps = 0.12; + float yx = crust(xz + vec2(eps, 0.0), t, h); + float yz = crust(xz + vec2(0.0, eps), t, h); + material.surfaceNormal.xyz = + normalize(vec3(-(yx - y0) / eps, 1.0, -(yz - y0) / eps)); + material.surfacePos.xyz = material.worldPosition.xyz; + } +} + +fragment { + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + float noise2(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), + mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); + } + + float fbm(vec2 p) { + float v = 0.0; + v += 0.5 * noise2(p); p *= 2.03; + v += 0.25 * noise2(p); p *= 2.01; + v += 0.125 * noise2(p); + return v / 0.875; + } + + void material(inout MaterialInputs material) { + prepareMaterial(material); + + vec3 pos = variable_surfacePos.xyz; + vec3 n = normalize(variable_surfaceNormal.xyz); + float t = materialParams.time; + + // Crust field: slowly drifting, domain-warped fbm. Cracks are the + // RIDGES of two warped fbm fields, intersected with max(): ridge + // lines form a connected web rather than isolated blobs, which is + // what a real cooling crust cracks into. + float s = materialParams.crustScale; + float warp = fbm(pos.xz * 0.6 + vec2(t * 0.03, -t * 0.02)); + float c1 = fbm(pos.xz * s + vec2(t * 0.05, -t * 0.04) + warp * 1.4); + float c2 = fbm(pos.xz * s * 2.1 + vec2(-t * 0.035, t * 0.03) + + warp * 1.1 + 31.7); + float r1 = 1.0 - abs(2.0 * c1 - 1.0); + float r2 = 1.0 - abs(2.0 * c2 - 1.0); + float crack = max(smoothstep(0.80, 0.965, r1), + smoothstep(0.84, 0.975, r2)); + // A wider, dimmer halo of reheat around the crack web. + float halo = max(smoothstep(0.62, 0.95, r1), + smoothstep(0.66, 0.96, r2)) - crack; + + // Fast-flowing texture inside the cracks sells the molten current; + // its sharpest lobes are the hottest (near-white) channels. + float flowN = fbm(pos.xz * 3.5 + + vec2(-t * materialParams.flowSpeed, + t * materialParams.flowSpeed * 0.7)); + float flow2 = fbm(pos.xz * 7.0 - + vec2(t * materialParams.flowSpeed * 1.8, + t * materialParams.flowSpeed * 0.5)); + float flow = flowN * 0.65 + flow2 * 0.35; + float core = pow(smoothstep(0.55, 0.95, flow), 2.0); + float glow = (crack + halo * 0.4) * (0.5 + 0.5 * flow); + + // Blackbody-ish ramp: deep red -> orange -> yellow-white. The + // scene's exposure + ACES lift and desaturate darks heavily, so + // both stops and crust color are pushed darker/more saturated than + // the target read. + vec3 glowCol = mix(vec3(0.75, 0.06, 0.0), vec3(1.0, 0.42, 0.05), + smoothstep(0.2, 0.55, glow)); + glowCol = mix(glowCol, vec3(1.0, 0.85, 0.4), + smoothstep(0.65, 0.95, glow)); + glowCol = mix(glowCol, vec3(1.0, 0.97, 0.88), + core * crack); + + // Dark crust with noise variation and gentle top-light shading; + // it warms slightly near the cracks (reheat). + float crustTex = fbm(pos.xz * 2.2 + 17.0); + float crustFine = fbm(pos.xz * 7.5 + 5.0); + vec3 crustCol = vec3(0.022, 0.009, 0.007) * + (0.55 + 0.7 * crustTex + 0.25 * crustFine); + crustCol *= 0.75 + 0.5 * max(dot(n, normalize(vec3(0.3, 0.8, 0.4))), 0.0); + crustCol += vec3(0.55, 0.05, 0.0) * halo * halo * 0.55; + // Slow regional pulse, as of convection cells below the crust. + float pulse = 0.85 + 0.15 * sin(t * 0.8 + warp * 6.0); + + vec3 color = crustCol * pulse + + glowCol * glow * materialParams.glowIntensity; + + // Melt the plane edge into the dark background. + float horizon = smoothstep(5.0, 7.6, length(pos.xz)); + color = mix(color, vec3(0.012, 0.006, 0.006), horizon); + + material.baseColor = vec4(color, 1.0); + } +} diff --git a/examples/assets/portal_rift.filamat b/examples/assets/portal_rift.filamat new file mode 100644 index 000000000..98730c8c2 Binary files /dev/null and b/examples/assets/portal_rift.filamat differ diff --git a/examples/assets/portal_rift.mat b/examples/assets/portal_rift.mat new file mode 100644 index 000000000..bc260cdbb --- /dev/null +++ b/examples/assets/portal_rift.mat @@ -0,0 +1,36 @@ +material { + name : PortalRift, + shadingModel : unlit, + requires : [ position ], + variables : [ objectPos ], + parameters : [ { type : float, name : time }, { type : float, name : openAmount } ], + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} +vertex { void materialVertex(inout MaterialVertexInputs material){material.objectPos.xyz=getPosition().xyz;} } +fragment { + float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);} + float noise2(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.0-2.0*f);return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);} + float fbm(vec2 p){float v=.0;v+=.5*noise2(p);p=p*2.03+3.1;v+=.25*noise2(p);p=p*2.01+7.7;v+=.125*noise2(p);return v/.875;} + void material(inout MaterialInputs material){ + prepareMaterial(material); + vec2 p=variable_objectPos.xz*2.0; + p.x/=max(materialParams.openAmount,.035); + float r=length(p); if(r>1.0) discard; + float a=atan(p.y,p.x), t=materialParams.time; + float warp=fbm(vec2(a*1.7-r*2.2,r*4.0-t*.75)); + float tunnel=.5+.5*cos(r*55.0-a*5.0+t*7.0+warp*11.0); + tunnel=pow(tunnel,5.0)*smoothstep(.95,.08,r); + float spiral=pow(.5+.5*sin(a*8.0-r*34.0+t*4.2+warp*8.0),8.0); + float stars=step(.982,hash(floor((p+vec2(t*.04,-t*.025))*52.0)))*smoothstep(.82,.15,r); + float rim=smoothstep(.82,.905,r)*(1.0-smoothstep(.94,1.0,r)); + float rimCore=exp(-abs(r-.91)*70.0); + float corona=pow(max(1.0-r,0.0),.3)*smoothstep(.84,.985,r)*(noise2(vec2(a*19.0,t*3.0))*.55+.45); + vec3 abyss=mix(vec3(.002,.004,.022),vec3(.055,.002,.13),r+warp*.18); + vec3 color=abyss+vec3(.08,.18,.85)*tunnel*.75+vec3(.62,.03,1.0)*spiral*.34; + color+=mix(vec3(.025,.16,.72),vec3(.42,.018,.78),warp)*rim*.92; + color+=vec3(.25,.5,1.0)*rimCore*.45; + color+=vec3(.08,.28,.78)*corona*.35+vec3(.7,.85,1.0)*stars*1.1; + material.baseColor=vec4(color,1.0); + } +} diff --git a/examples/assets/sand.filamat b/examples/assets/sand.filamat new file mode 100644 index 000000000..9d14e12d7 Binary files /dev/null and b/examples/assets/sand.filamat differ diff --git a/examples/assets/sand.mat b/examples/assets/sand.mat new file mode 100644 index 000000000..18744cb71 --- /dev/null +++ b/examples/assets/sand.mat @@ -0,0 +1,84 @@ +material { + name : Sand, + requires : [ position ], + variables : [ + worldPos + ], + parameters : [ + { type : float4, name : sandColor }, + { type : float, name : time } + ], + shadingModel : unlit, + blending : opaque, + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} + +vertex { + void materialVertex(inout MaterialVertexInputs material) { + // See shockwave_ground.mat: fragment getWorldPosition() is + // view-relative here; this plane sits at the identity transform, + // so object position IS world position. + // The beach geometry is translated +8.2 in Z and +0.08 in Y by the + // example. Pipe the matching analytic world coordinate explicitly so + // this material and shore_waves.mat evaluate the same contour. + material.worldPos.xyz = getPosition().xyz + vec3(0.0, 0.08, 8.2); + } +} + +fragment { + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + float noise2(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), + mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); + } + + float shoreZ(float x) { + return 1.2 + 0.35 * sin(x * 0.6 + 1.0) + 0.15 * sin(x * 1.7); + } + + void material(inout MaterialInputs material) { + prepareMaterial(material); + + vec3 pos = variable_worldPos.xyz; + float grain = noise2(pos.xz * 9.0) * 0.4 + noise2(pos.xz * 31.0) * 0.6; + vec3 color = materialParams.sandColor.rgb * (0.88 + 0.20 * grain); + + // Wet band hugging the same analytic shoreline the water uses: + // darker and slightly cool where the wash reaches. + float d = pos.z - shoreZ(pos.x); + // This plane overlaps the water geometry just enough to cover the + // land side. Clip its seaward half analytically so the same curved + // contour, not a rectangular mesh edge, defines the visible beach. + if (d < -0.12) { + discard; + } + float ripple = sin(pos.z * 18.0 + sin(pos.x * 1.8) * 1.6); + color *= 0.96 + 0.04 * ripple * smoothstep(3.5, 0.2, d); + // The swash line runs up the sand and retreats, phase-locked to the + // water's breaker pulse (t * 3.0 + depth * 2.8). + float swashC = 0.55 + 0.5 * sin(materialParams.time * 3.0 + 1.2); + float dr_7439 = (d - swashC) / 0.55; + float wet = exp(-dr_7439 * dr_7439); + // A trailing damp apron behind the leading swash edge. + float damp = smoothstep(swashC + 0.9, swashC - 0.2, d) * + smoothstep(-2.5, -0.5, d); + color = mix(color, color * vec3(0.42, 0.39, 0.37) + vec3(0.0, 0.008, 0.012), + clamp(wet + damp, 0.0, 1.0) * 0.85); + // Fine foam speckle right at the swash edge. + float speck = wet * (0.12 + 0.18 * noise2(pos.xz * 12.0)); + color += vec3(0.035) * speck; + + // Melt into the dark background far from the water. + float far = smoothstep(1.8, 5.5, d); + color = mix(color, vec3(0.03, 0.028, 0.03), far); + + material.baseColor = vec4(color, 1.0); + } +} diff --git a/examples/assets/shockwave_dome.filamat b/examples/assets/shockwave_dome.filamat new file mode 100644 index 000000000..423d8cb37 Binary files /dev/null and b/examples/assets/shockwave_dome.filamat differ diff --git a/examples/assets/shockwave_dome.mat b/examples/assets/shockwave_dome.mat new file mode 100644 index 000000000..a60b6e350 --- /dev/null +++ b/examples/assets/shockwave_dome.mat @@ -0,0 +1,90 @@ +material { + name : ShockwaveDome, + requires : [ position, tangents ], + variables : [ + surfaceNormal, + objPos + ], + parameters : [ + { type : float4, name : baseColor }, + { type : float, name : time }, + { type : float, name : age } + ], + depthWrite : false, + depthCulling : false, + shadingModel : unlit, + blending : add, + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} + +vertex { + void materialVertex(inout MaterialVertexInputs material) { + // Object-space position of the unit sphere (the Dart animator + // scales the renderable uniformly, so object space stays + // proportional and the equator/hemisphere logic below is + // scale-invariant). + material.objPos.xyz = getPosition().xyz; + + // Fresnel needs the view direction, but fragment-stage positions + // are unreliable on this pipeline (see shockwave_ground.mat). + // Compute the rim in the vertex stage from the view-space normal + // instead: the angle to the view axis (0, 0, 1) needs no position. + vec3 viewN = normalize((getViewFromWorldMatrix() * + vec4(material.worldNormal, 0.0)).xyz); + float ndv = abs(viewN.z); + // .w channels carry the rim terms for the fragment stage. + material.objPos.w = 1.0 - ndv; // fresnel base + material.surfaceNormal.w = pow(1.0 - ndv, 4.0); // hot lip + } +} + +fragment { + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + float noise2(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), + mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); + } + + void material(inout MaterialInputs material) { + prepareMaterial(material); + + vec3 pos = variable_objPos.xyz; + float rimBase = variable_objPos.w; + float lip = variable_surfaceNormal.w; + float t = materialParams.time; + + // Only the upper hemisphere: the dome rises out of the ground + // plane, and the equator cut sits exactly at that plane. + if (pos.y < 0.0) { + discard; + } + + float fresnel = pow(rimBase, 2.5); + + // Energy shimmer flowing up the dome: noise stretched vertically + // into rising streaks, plus a hot lip around the equator where the + // dome meets the ground. + float streaks = 0.55 + 0.45 * noise2(vec2(pos.x * 2.0 + pos.z * 1.3, + pos.y * 6.0) - + vec2(0.0, t * 2.5)); + float dr_eq = pos.y * 4.5; + float equator = exp(-dr_eq * dr_eq); + + float fade = exp(-materialParams.age * 1.3); + float amount = (fresnel * 2.4 + lip * 1.5 + equator * 1.2) * + streaks * fade; + + vec3 color = mix(materialParams.baseColor.rgb, vec3(0.9, 0.98, 1.0), + max(lip, equator) * 0.6); + // Additive materials pass through the engine's HDR exposure before + // tonemapping on this pipeline - see hit_flash.mat. + material.baseColor = vec4(color * amount * 0.07, 1.0); + } +} diff --git a/examples/assets/shockwave_ground.filamat b/examples/assets/shockwave_ground.filamat new file mode 100644 index 000000000..0e074a46b Binary files /dev/null and b/examples/assets/shockwave_ground.filamat differ diff --git a/examples/assets/shockwave_ground.mat b/examples/assets/shockwave_ground.mat new file mode 100644 index 000000000..85d01103c --- /dev/null +++ b/examples/assets/shockwave_ground.mat @@ -0,0 +1,93 @@ +material { + name : ShockwaveGround, + requires : [ position ], + variables : [ + worldPos + ], + parameters : [ + { type : float, name : time }, + { type : float, name : period }, + { type : float, name : waveSpeed } + ], + depthWrite : false, + depthCulling : false, + shadingModel : unlit, + blending : add, + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} + +vertex { + void materialVertex(inout MaterialVertexInputs material) { + // getWorldPosition() in the fragment stage is view-relative on + // this pipeline, and getWorldFromModelMatrix() returns garbage for + // created geometry - but this plane sits at the identity + // transform, so object position IS world position. + material.worldPos.xyz = getPosition().xyz; + } +} + +fragment { + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + float noise2(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), + mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); + } + + float fbm(vec2 p) { + float v = 0.0; + v += 0.5 * noise2(p); p *= 2.03; + v += 0.25 * noise2(p); + return v / 0.75; + } + + void material(inout MaterialInputs material) { + prepareMaterial(material); + + vec3 pos = variable_worldPos.xyz; + float t = materialParams.time; + float r = length(pos.xz); + + // One wave per period, expanding from the epicenter. + float age = mod(t, materialParams.period) / materialParams.period; + float waveR = age * materialParams.period * materialParams.waveSpeed; + + // Main energy ring plus a trailing secondary ring. The leading + // edge is sharp; the falloff behind it is softer. Width relaxes + // with radius so the ring stays readable as it expands. + float wgt = 4.6 / (0.30 + age * 1.0); + float dr_8797 = (r - waveR) * wgt; + float dr_5976 = (r - waveR + 0.25) * wgt * 0.45; + float band = exp(-dr_8797 * dr_8797) + + 0.35 * exp(-dr_5976 * dr_5976); + float dr_5293 = (r - waveR * 0.78) * wgt * 1.5; + float trail = exp(-dr_5293 * dr_5293) * 0.45; + + // Lingering energy fill behind the front, and a hot flash at the + // epicenter at the instant of the pulse. + float fill = exp(-r * 0.9) * (1.0 - age) * 0.5; + float epi = exp(-r * 2.5) * exp(-age * 7.0) * 2.2; + + // Break the ring into arcs so it reads as energy, not a perfect + // circle, with fine churn riding the front. + float ang = atan(pos.z, pos.x); + float arc = fbm(vec2(ang * 2.5, r * 0.7) + t * 0.2); + float churn = 0.7 + 0.5 * noise2(pos.xz * 6.0 - t * 3.0); + + float fade = pow(1.0 - age, 1.2); + float amount = (band * 2.2 + trail + fill + epi) * + (0.25 + 0.85 * arc) * churn * fade; + + vec3 color = mix(vec3(0.30, 0.85, 1.0), vec3(0.85, 0.98, 1.0), band); + color = mix(color, vec3(0.95, 0.98, 1.0), clamp(epi * 0.4, 0.0, 0.6)); + // Additive materials pass through the engine's HDR exposure before + // tonemapping on this pipeline - see hit_flash.mat. + material.baseColor = vec4(color * amount * 0.075, 1.0); + } +} diff --git a/examples/assets/shore_waves.filamat b/examples/assets/shore_waves.filamat new file mode 100644 index 000000000..431a652da Binary files /dev/null and b/examples/assets/shore_waves.filamat differ diff --git a/examples/assets/shore_waves.mat b/examples/assets/shore_waves.mat new file mode 100644 index 000000000..f0d8f6c15 --- /dev/null +++ b/examples/assets/shore_waves.mat @@ -0,0 +1,203 @@ +material { + name : ShoreWaves, + requires : [ position, tangents ], + variables : [ + surfaceNormal, + surfacePos + ], + parameters : [ + { type : float4, name : deepColor }, + { type : float4, name : shallowColor }, + { type : float4, name : skyColor }, + { type : float4, name : foamColor }, + { type : float3, name : sunDirection }, + { type : float, name : time }, + { type : float, name : waveHeight }, + { type : float, name : waveFrequency }, + { type : float, name : waveSpeed }, + { type : float, name : foamAmount }, + { type : float, name : detailStrength } + ], + depthWrite : true, + depthCulling : true, + shadingModel : unlit, + blending : transparent, + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} + +vertex { + // Analytic shoreline: the demo controls the scene geometry, so shore + // distance is a known function - no depth texture needed. `offshore` + // is positive on the water side and negative after crossing onto land. + float shoreZ(float x) { + return 1.2 + 0.35 * sin(x * 0.6 + 1.0) + 0.15 * sin(x * 1.7); + } + + // Gerstner displacement with a per-vertex amplitude: swells shoal + // (grow) as they approach the shore, then collapse past the break. + vec3 swell(vec2 p, vec2 dir, float freq, float speed, float amp, + float steep, float t) { + float phase = dot(dir, p) * freq - t * speed; + float s = sin(phase); + float c = cos(phase); + return vec3(steep * amp * dir.x * c, amp * s, steep * amp * dir.y * c); + } + + vec3 swellSum(vec2 p, float t, float ampScale) { + float h = materialParams.waveHeight * ampScale; + float f = materialParams.waveFrequency; + float sp = materialParams.waveSpeed; + vec3 w = swell(p, normalize(vec2( 0.12, 0.99)), f * 1.0, sp * 1.0, h * 1.00, 0.50, t); + w += swell(p, normalize(vec2(-0.20, 0.98)), f * 2.3, sp * 1.35, h * 0.45, 0.40, t); + w += swell(p, normalize(vec2( 0.30, 0.95)), f * 4.1, sp * 1.80, h * 0.20, 0.30, t); + return w; + } + + void materialVertex(inout MaterialVertexInputs material) { + vec2 xz = material.worldPosition.xz; + float t = materialParams.time; + float offshore = shoreZ(xz.x) - xz.y; + + // Shoaling: amplitude grows through the last few metres offshore, + // then collapses quickly after the waterline. + float shoal = 0.55 + 0.45 * (1.0 - smoothstep(0.7, 5.5, offshore)); + float landCollapse = smoothstep(-0.75, 0.12, offshore); + float ampScale = shoal * landCollapse; + + vec3 disp = swellSum(xz, t, ampScale); + // Additive displacement only - see the NOTE in smoke.mat. + material.worldPosition.xyz += disp; + + float eps = 0.06; + vec3 sx = vec3(eps, 0.0, 0.0) + swellSum(xz + vec2(eps, 0.0), t, ampScale) - disp; + vec3 sz = vec3(0.0, 0.0, eps) + swellSum(xz + vec2(0.0, eps), t, ampScale) - disp; + material.surfaceNormal.xyz = normalize(cross(sz, sx)); + + material.surfacePos.xyz = material.worldPosition.xyz; + // .w channels: crest height and horizontal pinch (whitecap inputs). + material.surfacePos.w = clamp(disp.y / (1.65 * materialParams.waveHeight + 0.001), -1.0, 1.0); + material.surfaceNormal.w = clamp(1.0 - min(length(sx), length(sz)) / eps, 0.0, 1.0); + } +} + +fragment { + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + float noise2(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), + mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); + } + + float fbm(vec2 p) { + float v = 0.0; + v += 0.5 * noise2(p); p *= 2.03; + v += 0.25 * noise2(p); p *= 2.01; + v += 0.125 * noise2(p); + return v / 0.875; + } + + vec2 fbmGrad(vec2 p, float eps) { + float n0 = fbm(p); + return vec2(fbm(p + vec2(eps, 0.0)) - n0, + fbm(p + vec2(0.0, eps)) - n0) / eps; + } + + float shoreZ(float x) { + return 1.2 + 0.35 * sin(x * 0.6 + 1.0) + 0.15 * sin(x * 1.7); + } + + void material(inout MaterialInputs material) { + prepareMaterial(material); + + vec3 pos = variable_surfacePos.xyz; + vec3 n = normalize(variable_surfaceNormal.xyz); + vec3 viewDir = normalize(getWorldCameraPosition() - pos); + vec3 sun = normalize(-materialParams.sunDirection); + float t = materialParams.time; + float offshore = shoreZ(pos.x) - pos.z; + + // --- detail normals for shimmer ------------------------------- + float dist = length(getWorldCameraPosition() - pos); + float detailAtt = exp(-dist * 0.10) * materialParams.detailStrength; + vec2 g1 = fbmGrad(pos.xz * 1.1 + vec2(t * 0.10, t * 0.16), 0.30); + vec2 g2 = fbmGrad(pos.xz * 4.2 - vec2(t * 0.22, t * 0.31), 0.15); + vec3 nD = normalize( + n + vec3(g1.x, 0.0, g1.y) * 0.45 * detailAtt + + vec3(g2.x, 0.0, g2.y) * 0.40 * detailAtt); + + // --- body color: deep offshore, turquoise shallows ------------- + float shallowMix = 1.0 - smoothstep(0.2, 2.8, offshore); + float ndv = max(dot(nD, viewDir), 0.0); + float fresnel = 0.03 + 0.97 * pow(1.0 - ndv, 3.5); + vec3 color = mix(materialParams.deepColor.rgb, + materialParams.shallowColor.rgb, shallowMix); + color = mix(color, materialParams.skyColor.rgb, fresnel * (1.0 - shallowMix * 0.35)); + color *= 0.80 + 0.28 * clamp(dot(nD, sun), 0.0, 1.0); + + // Sun glitter toward the shore. + vec3 halfDir = normalize(viewDir + sun); + float ndh = max(dot(nD, halfDir), 0.0); + float sparkle = pow(ndh, 480.0) * (0.35 + 0.65 * clamp(2.0 * fresnel, 0.0, 1.0)); + color += vec3(1.0, 0.9, 0.7) * (pow(ndh, 90.0) * 0.20 + sparkle) * 2.0; + + // --- foam -------------------------------------------------------- + // (a) whitecaps on shoaling crests (pinch + height) + float crest = clamp(variable_surfacePos.w, 0.0, 1.0); + float chop = clamp(variable_surfaceNormal.w, 0.0, 1.0); + float foamNoise = fbm(pos.xz * 2.6 + vec2(t * 0.14, t * 0.20)); + float crestFoam = smoothstep(0.40, 0.76, + clamp(crest * 1.15 + chop * 2.25 - 0.28, 0.0, 1.0) * + (0.24 + 0.95 * foamNoise * foamNoise)); + crestFoam *= 1.0 - smoothstep(1.2, 3.8, offshore); + + // (b) the breaking line: a band around the shoreline, pulsing + // with swell arrival, textured anisotropically ALONG the shore. + float dr_4588 = offshore / 0.38; + float breakBand = exp(-dr_4588 * dr_4588); + // Pulse crest travels TOWARD the shore (depth decreasing) with an + // along-shore phase wobble so the break line advances unevenly. + float pulse = 0.5 + 0.5 * sin(t * 3.0 + offshore * 2.8 + + 0.8 * sin(pos.x * 0.45)); + float shoreN = fbm(vec2(pos.x * 1.1, pos.z * 5.5) + + vec2(t * 0.15, t * 0.5)); + float breaker = breakBand * + (0.08 + 0.92 * smoothstep(0.42, 0.82, pulse)) * + (0.22 + 1.05 * shoreN) * + (0.48 + 0.72 * noise2(vec2(pos.x * 4.5, pos.z * 16.0) + + vec2(0.0, t * 1.4))); + + // A second, lower breaker approaches behind the main swash. The + // offset varies along shore so it forms fingers and curls rather + // than a parallel procedural stripe. + float incomingC = 0.92 + 0.18 * sin(t * 1.25 + pos.x * 0.38); + float drIncoming = (offshore - incomingC) / 0.24; + float incoming = exp(-drIncoming * drIncoming) * + (0.2 + 0.8 * shoreN) * + (0.35 + 0.65 * smoothstep(0.1, 0.9, pulse)) * 0.52; + + // (c) faint wash residue further up the sand. + float dr_1914 = ((offshore + 0.55) / 0.32); + float wash = exp(-dr_1914 * dr_1914) * + 0.18 * (0.4 + 0.6 * shoreN); + + float foam = clamp(max(max(crestFoam, breaker), incoming) + wash, + 0.0, 1.0) * + materialParams.foamAmount; + vec3 nShaded = normalize(mix(nD, vec3(0.0, 1.0, 0.0), foam * 0.8)); + color = mix(color, materialParams.foamColor.rgb, foam * 0.92); + + // Water melts into the sand past the shoreline. + float shoreFade = smoothstep(-0.75, 0.15, + offshore + 0.22 * (shoreN - 0.5)); + float alpha = 0.92 * materialParams.deepColor.a; + alpha = max(alpha, foam) * shoreFade; + + material.baseColor = vec4(color, alpha); + } +} diff --git a/examples/assets/smoke.filamat b/examples/assets/smoke.filamat new file mode 100644 index 000000000..1f7274293 Binary files /dev/null and b/examples/assets/smoke.filamat differ diff --git a/examples/assets/smoke.mat b/examples/assets/smoke.mat new file mode 100644 index 000000000..8e9f72389 --- /dev/null +++ b/examples/assets/smoke.mat @@ -0,0 +1,182 @@ +material { + name : Smoke, + variables : [ + quadData + ], + parameters : [ + { type : float4, name : baseColor }, + { type : float, name : time }, + { type : float, name : puffCount }, + { type : float, name : riseSpeed }, + { type : float, name : expandSpeed }, + { type : float, name : swirlAmount }, + { type : float, name : baseSize }, + { type : float, name : noiseScale }, + { type : float, name : lifetime }, + { type : float, name : originHeight }, + { type : float, name : opacity } + ], + depthWrite : false, + depthCulling : false, + shadingModel : unlit, + blending : transparent, + culling : none, + featureLevel : 1, + vertexDomain : object +} + +vertex { + void materialVertex(inout MaterialVertexInputs material) { + // One billboard puff per 6 vertices (two triangles), generated + // entirely in the vertex shader - the CPU-side geometry is a dummy. + int vid = getVertexIndex(); + int quadIndex = vid / 6; + int vertInQuad = vid - quadIndex * 6; + + // Per-puff deterministic seeds. + float seed = float(quadIndex); + float rng1 = fract(sin(seed * 127.1) * 43758.5453); + float rng2 = fract(sin(seed * 269.5) * 43758.5453); + float rng3 = fract(sin(seed * 419.2) * 43758.5453); + float rng4 = fract(sin(seed * 113.5) * 43758.5453); + + vec2 positions[6] = vec2[6]( + vec2(-0.5, -0.5), vec2( 0.5, -0.5), vec2(-0.5, 0.5), + vec2( 0.5, -0.5), vec2( 0.5, 0.5), vec2(-0.5, 0.5) + ); + vec2 uvs[6] = vec2[6]( + vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0), + vec2(1.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 1.0) + ); + + // Staggered lifetimes: each puff loops over [0, lifetime). + float t = mod(materialParams.time - rng1 * materialParams.lifetime, + materialParams.lifetime); + float age = t / materialParams.lifetime; + + // Rise with per-puff rate variation; puffs accelerate slightly as + // they heat up, then ride the plume. + float y = t * materialParams.riseSpeed * (0.85 + rng2 * 0.3) * + (0.75 + 0.5 * age); + + // Puffs start compact, broaden gradually, and only stretch modestly. + // Oversized late-life billboards merge into a flat white wall, so + // most of the plume volume comes from overlap between many wisps. + float scale = (materialParams.baseSize + + t * materialParams.expandSpeed) * (0.75 + rng3 * 0.5); + vec2 quadScale = vec2(scale * (0.78 + 0.30 * rng2), + scale * (1.0 + age * 0.72)); + + // Slow per-puff spin - visible through the noise sampling. + float angle = t * materialParams.swirlAmount * (rng1 - 0.5) * 0.8 + + seed * 2.4; + float ca = cos(angle); + float sa = sin(angle); + vec2 p = positions[vertInQuad] * quadScale; + p = vec2(p.x * ca - p.y * sa, p.x * sa + p.y * ca); + + // Column structure: emit from a small disc, then spiral outward with + // age while wind bends the plume sideways. This cone + bend is what + // makes it read as a plume rather than a vertical line of blobs. + float spiralAngle = seed * 6.2832 + t * 1.1; + float spiralRadius = age * (0.22 + 0.20 * rng4); + vec3 center = vec3( + cos(spiralAngle) * spiralRadius + (rng2 - 0.5) * 0.10 + + age * age * 0.34, + materialParams.originHeight + y, + sin(spiralAngle) * spiralRadius + (rng3 - 0.5) * 0.12); + + // Camera-facing: offset by the view axes (rows of the view matrix) + // expressed in world space, so no double transform occurs. + mat4 view = getViewFromWorldMatrix(); + vec3 camRight = vec3(view[0].x, view[1].x, view[2].x); + vec3 camUp = vec3(view[0].y, view[1].y, view[2].y); + + // NOTE: additive displacement only. Replacing worldPosition (or + // cancelling it out via += target - worldPosition) is lost through + // this Filament/Metal pipeline - only pure += offsets survive. + // The CPU-side positions are therefore kept small (near the + // origin, but non-degenerate so the bounding volume stays valid) + // and merely add a little per-puff jitter. + material.worldPosition.xyz += center + camRight * p.x + camUp * p.y; + + // uv + age fade + per-puff brightness in the variable's zw channels + // (fragment-stage getUV0 is only vec2). + material.quadData = vec4(uvs[vertInQuad], 1.0 - age, + 0.55 + 0.6 * rng4); + } +} + +fragment { + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + float noise2(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), + mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); + } + + float fbm(vec2 p) { + float v = 0.0; + v += 0.5 * noise2(p); p *= 2.03; + v += 0.25 * noise2(p); p *= 2.01; + v += 0.125 * noise2(p); + return v / 0.875; + } + + void material(inout MaterialInputs material) { + prepareMaterial(material); + + vec2 uv = variable_quadData.xy; + float fade = variable_quadData.z; // 1 at birth, 0 at death + float brightness = variable_quadData.w; + float age = 1.0 - fade; + float t = materialParams.time; + + // Soft asymmetric falloff. A warped ellipse removes the circular + // cotton-ball silhouette before the texture noise is applied. + vec2 d = (uv - 0.5) * vec2(1.0, 0.82); + + // Domain-warped fbm in billboard space. The noise is squashed + // vertically so its features stretch into rising wisps, and the + // double warp tears the edges instead of leaving cottony blobs. + vec2 q = uv * vec2(materialParams.noiseScale, + materialParams.noiseScale * 0.55) + + vec2(0.0, -t * 0.22); + float warp = fbm(q + vec2(t * 0.05, 0.0)); + vec2 warpedD = d + vec2(warp - 0.5, + fbm(q.yx * 1.7 - t * 0.08) - 0.5) * 0.20; + float dr = length(warpedD) * 2.0; + float radial = 1.0 - smoothstep(0.34, 1.0, dr); + radial = pow(max(radial, 0.0), 1.35); + float f = fbm(q + warp * 1.9 + fbm(q * 1.7) * 0.65); + float erosion = smoothstep(0.30, 0.68, + f + radial * 0.42 - age * 0.08); + float body = radial * erosion; + + // Dense and bright at birth, thinning as the puff climbs. + float density = pow(fade, 0.62); + body *= 0.32 + 0.60 * density; + + // Ease in quickly, fade out over the last stretch of life. + float lifeFade = smoothstep(0.0, 0.10, age) * + smoothstep(0.0, 0.28, fade); + + float amount = body * lifeFade * brightness * 0.22 * + materialParams.opacity; + + // Gray smoke with volume: lit from above (younger, denser puffs + // scatter more), a warm cast near the fire below, and a cooler, + // more translucent tone as it ages into wisps. + float lit = 0.40 + 0.52 * uv.y + 0.10 * f; + vec3 color = materialParams.baseColor.rgb * lit; + color += vec3(0.09, 0.025, 0.0) * density * density; + color = mix(color, color * vec3(0.54, 0.62, 0.78), age * 0.48); + + material.baseColor = vec4(color * amount, amount); + } +} diff --git a/examples/assets/snow_accumulation.filamat b/examples/assets/snow_accumulation.filamat new file mode 100644 index 000000000..caf896d7b Binary files /dev/null and b/examples/assets/snow_accumulation.filamat differ diff --git a/examples/assets/snow_accumulation.mat b/examples/assets/snow_accumulation.mat new file mode 100644 index 000000000..6ee3a292f --- /dev/null +++ b/examples/assets/snow_accumulation.mat @@ -0,0 +1,44 @@ +material { + name : SnowAccumulation, + shadingModel : unlit, + requires : [ position, tangents ], + variables : [ objectPos, surfaceNormal ], + parameters : [ + { type : float, name : time }, + { type : float, name : accumulation } + ], + culling : none, + variantFilter : [ shadowReceiver, vsm ] +} +vertex { + void materialVertex(inout MaterialVertexInputs material) { + material.objectPos.xyz = getPosition().xyz; + material.surfaceNormal.xyz = material.worldNormal; + } +} +fragment { + float hash(vec3 p) { return fract(sin(dot(p, vec3(127.1,311.7,74.7))) * 43758.5453); } + float noise3(vec3 p) { + vec3 i=floor(p),f=fract(p); f=f*f*(3.0-2.0*f); + return mix(mix(mix(hash(i),hash(i+vec3(1,0,0)),f.x),mix(hash(i+vec3(0,1,0)),hash(i+vec3(1,1,0)),f.x),f.y),mix(mix(hash(i+vec3(0,0,1)),hash(i+vec3(1,0,1)),f.x),mix(hash(i+vec3(0,1,1)),hash(i+vec3(1,1,1)),f.x),f.y),f.z); + } + void material(inout MaterialInputs material) { + prepareMaterial(material); + vec3 p=variable_objectPos.xyz, n=normalize(variable_surfaceNormal.xyz); + float slope=smoothstep(.12,.72,n.y); + float banks=noise3(p*5.5)*.18+noise3(p*15.0)*.06; + float line=mix(1.2,-.9,materialParams.accumulation); + float cover=smoothstep(line-.2,line+.14,p.y+slope*.65+banks); + cover*=smoothstep(.02,.55,slope+materialParams.accumulation*.3); + float frost=smoothstep(.72,.98,noise3(p*32.0+vec3(0,materialParams.time*.025,0)))*cover; + vec3 substrate=mix(vec3(.008,.011,.016),vec3(.055,.026,.012),noise3(p*7.0)); + vec3 snow=mix(vec3(.12,.2,.29),vec3(.64,.78,.9),slope)*(.8+.2*noise3(p*18.0)); + vec3 key=normalize(vec3(-.45,.75,.48)); + float diffuse=.18+.82*max(dot(n,key),0.0); + float rim=pow(1.0-abs(n.z),3.0); + vec3 color=mix(substrate,snow,cover)*diffuse; + color+=vec3(.07,.16,.25)*rim*(.25+.75*cover); + color+=vec3(.55,.78,1.0)*frost*.38; + material.baseColor=vec4(color,1.0); + } +} diff --git a/examples/assets/water.filamat b/examples/assets/water.filamat new file mode 100644 index 000000000..12ab512fb Binary files /dev/null and b/examples/assets/water.filamat differ diff --git a/examples/assets/water.mat b/examples/assets/water.mat new file mode 100644 index 000000000..bcf334075 --- /dev/null +++ b/examples/assets/water.mat @@ -0,0 +1,211 @@ +material { + name : Water, + requires : [ position, tangents ], + variables : [ + surfaceNormal, + surfacePos + ], + parameters : [ + { type : float4, name : deepColor }, + { type : float4, name : skyColor }, + { type : float4, name : foamColor }, + { type : float3, name : sunDirection }, + { type : float, name : time }, + { type : float, name : waveHeight }, + { type : float, name : waveFrequency }, + { type : float, name : waveSpeed }, + { type : float, name : foamAmount }, + { type : float, name : specularPower }, + { type : float, name : specularIntensity }, + { type : float, name : detailStrength }, + { type : float, name : sssStrength } + ], + depthWrite : true, + depthCulling : true, + shadingModel : unlit, + blending : transparent, + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} + +vertex { + // Sum of four Gerstner waves displaced from a flat XZ grid. Frequencies + // are ~1x/2x/4x/7x with rotated directions so no two crests align. + vec3 gerstner(vec2 p, vec2 dir, float freq, float speed, float amp, + float steep) { + float phase = dot(dir, p) * freq - materialParams.time * speed; + float s = sin(phase); + float c = cos(phase); + return vec3(steep * amp * dir.x * c, amp * s, steep * amp * dir.y * c); + } + + vec3 waveSum(vec2 p) { + float h = materialParams.waveHeight; + float f = materialParams.waveFrequency; + float sp = materialParams.waveSpeed; + vec3 w = gerstner(p, normalize(vec2( 1.00, 0.25)), f * 1.0, sp * 1.0, h * 1.00, 0.85); + w += gerstner(p, normalize(vec2(-0.62, 0.78)), f * 2.0, sp * 1.35, h * 0.48, 0.60); + w += gerstner(p, normalize(vec2( 0.34, -0.94)), f * 3.9, sp * 1.90, h * 0.22, 0.42); + w += gerstner(p, normalize(vec2(-0.85, -0.53)), f * 6.9, sp * 2.60, h * 0.12, 0.30); + w += gerstner(p, normalize(vec2( 0.90, -0.44)), f * 10.8, sp * 3.30, h * 0.07, 0.22); + return w; + } + + void materialVertex(inout MaterialVertexInputs material) { + vec3 world = material.worldPosition.xyz; + vec3 disp = waveSum(world.xz); + // Read-modify-write: replacing worldPosition outright + // (worldPosition.xyz = ) is lost through this + // Filament/Metal pipeline - only displacement forms survive. + material.worldPosition.xyz += disp; + + // Finite-difference tangents of the full displaced surface. + // Surface(x,z) = (x,0,z) + waveSum(x,z); cross(Sz, Sx) points up. + float eps = 0.06; + vec3 sx = vec3(eps, 0.0, 0.0) + waveSum(world.xz + vec2(eps, 0.0)) - disp; + vec3 sz = vec3(0.0, 0.0, eps) + waveSum(world.xz + vec2(0.0, eps)) - disp; + material.surfaceNormal.xyz = normalize(cross(sz, sx)); + + // Foam inputs, packed into the unused .w channels: + // - surfacePos.w: crest height, normalized by the sum of amplitudes + // (1.82 * waveHeight with the ratios above). + // - surfaceNormal.w: horizontal compression (Gerstner pinch). Where + // the wave converges, the tangent vectors shrink below eps - that + // is exactly where whitecaps live. + material.surfacePos.xyz = material.worldPosition.xyz; + material.surfacePos.w = + clamp(disp.y / (1.89 * materialParams.waveHeight + 0.001), -1.0, 1.0); + material.surfaceNormal.w = + clamp(1.0 - min(length(sx), length(sz)) / eps, 0.0, 1.0); + } +} + +fragment { + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + + float noise2(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), + mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); + } + + float fbm(vec2 p) { + float v = 0.0; + v += 0.5 * noise2(p); p *= 2.03; + v += 0.25 * noise2(p); p *= 2.01; + v += 0.125 * noise2(p); + return v / 0.875; + } + + // Slope of the fbm field, used to perturb the normal for high-frequency + // sparkle that the grid itself cannot carry. + vec2 fbmGrad(vec2 p, float eps) { + float n0 = fbm(p); + return vec2(fbm(p + vec2(eps, 0.0)) - n0, + fbm(p + vec2(0.0, eps)) - n0) / eps; + } + + void material(inout MaterialInputs material) { + prepareMaterial(material); + + vec3 pos = variable_surfacePos.xyz; + vec3 n = normalize(variable_surfaceNormal.xyz); + vec3 viewDir = normalize(getWorldCameraPosition() - pos); + vec3 sun = normalize(-materialParams.sunDirection); + float t = materialParams.time; + + // --- detail normals: three scrolled fbm layers ------------------ + // Frequencies span swell-ripple (0.9) down to near-pixel (11) - the + // fine layers are what make the sun glitter actually sparkle. + float dist = length(getWorldCameraPosition() - pos); + float detailAttenuation = exp(-dist * 0.08) * + materialParams.detailStrength; + vec2 g1 = fbmGrad(pos.xz * 0.90 + vec2(t * 0.11, t * 0.05), 0.35); + vec2 g2 = fbmGrad(pos.xz * 5.50 - vec2(t * 0.23, t * 0.31), 0.15); + vec2 g3 = fbmGrad(pos.xz * 11.0 + vec2(-t * 0.41, t * 0.17), 0.08); + vec3 nDetailed = normalize( + n + vec3(g1.x, 0.0, g1.y) * 0.40 * detailAttenuation + + vec3(g2.x, 0.0, g2.y) * 0.55 * detailAttenuation + + vec3(g3.x, 0.0, g3.y) * 0.45 * detailAttenuation); + + // --- foam ------------------------------------------------------ + float crest = clamp(variable_surfacePos.w, 0.0, 1.0); + float chop = clamp(variable_surfaceNormal.w, 0.0, 1.0); + float foamNoise = fbm(pos.xz * 2.6 + vec2(t * 0.14, -t * 0.09)); + float foamFine = noise2(pos.xz * 9.0 + vec2(-t * 0.5, t * 0.3)); + float foamMask = clamp(crest * 1.15 + chop * 2.35 - 0.24, 0.0, 1.0); + float foam = smoothstep(0.48, 0.84, + foamMask * (0.22 + 1.02 * foamNoise * foamNoise)); + foam = clamp(foam * materialParams.foamAmount * + (0.55 + 0.6 * foamFine), 0.0, 1.0); + // Foam is diffuse: flatten the normal where it covers. + vec3 nShaded = normalize(mix(nDetailed, vec3(0.0, 1.0, 0.0), foam * 0.8)); + + // --- water body color ------------------------------------------- + // Schlick fresnel: looking down sees deep water, grazing angles + // reflect the sky. + float ndv = max(dot(nShaded, viewDir), 0.0); + float fresnel = 0.02 + 0.98 * pow(1.0 - ndv, 3.5); + + // Large-scale current variation breaks up the single-tone body. + float cur = fbm(pos.xz * 0.35 + vec2(t * 0.015, -t * 0.01)); + vec3 deep = materialParams.deepColor.rgb * (0.72 + 0.56 * cur); + vec3 color = mix(deep, + materialParams.skyColor.rgb, fresnel); + + // Sun-side shading: wave flanks facing the sun read brighter, troughs + // darker - this is what gives the surface visible relief. + color *= 0.72 + 0.38 * clamp(dot(nShaded, sun), 0.0, 1.0); + color *= 0.90 + 0.16 * clamp(crest + 0.35, 0.0, 1.0); + color += materialParams.skyColor.rgb * 0.10 * clamp(crest, 0.0, 1.0); + + // Subsurface glow: light punching through backlit crests. Strongest + // looking toward the sun through the top of a wave. + float towardSun = pow(max(dot(viewDir, -sun), 0.0) * 0.5 + 0.5, 3.0); + float sss = towardSun * clamp(crest, 0.0, 1.0) * + clamp(1.0 - ndv, 0.0, 1.0) * materialParams.sssStrength; + color += vec3(0.06, 0.45, 0.42) * sss; + + // --- sun glitter: broad sheen + tight sparkle off detail normals - + vec3 halfDir = normalize(viewDir + sun); + float ndh = max(dot(nShaded, halfDir), 0.0); + float sheen = pow(ndh, 90.0) * 0.22; + // Sparkle concentrates near grazing angles, like real water glints. + float glint = hash(floor(pos.xz * 16.0) + floor(vec2(t * 6.0))); + // Concentrate the glints into a wedge pointing at the sun so they + // read as a glitter path rather than uniform speckle. + vec2 camXZ = getWorldCameraPosition().xz; + vec2 toPoint = pos.xz - camXZ; + float sunSide = max(dot(normalize(toPoint), normalize(sun.xz)), 0.0); + float pathGate = 0.25 + 0.75 * pow(sunSide, 3.0); + float sparkle = pow(ndh, materialParams.specularPower) * + (0.3 + 0.7 * clamp(2.0 * fresnel, 0.0, 1.0)) * + (0.45 + 0.9 * glint) * pathGate; + color += vec3(1.0, 0.87, 0.65) * + (sheen + sparkle) * materialParams.specularIntensity; + + // --- foam on top ------------------------------------------------ + vec3 foamCol = materialParams.foamColor.rgb * + (0.8 + 0.25 * foamFine); + color = mix(color, foamCol, foam * 0.95); + + // --- distance haze: melt the plane edge into the horizon --------- + float horizon = smoothstep(5.5, 11.0, length(pos.xz)); + vec3 hazeColor = mix(materialParams.skyColor.rgb, + materialParams.deepColor.rgb * 2.0, 0.35) * 0.4; + color = mix(color, hazeColor, horizon); + + float alpha = clamp(0.78 + 0.22 * fresnel, 0.0, 1.0) * + materialParams.deepColor.a; + alpha = max(alpha, foam); + // Fade alpha out at the rim so the plane's edge never reads as a + // hard disc against the skybox. + alpha *= 1.0 - horizon; + + material.baseColor = vec4(color, alpha); + } +} diff --git a/examples/assets/wetness.filamat b/examples/assets/wetness.filamat new file mode 100644 index 000000000..a8b32a0ec Binary files /dev/null and b/examples/assets/wetness.filamat differ diff --git a/examples/assets/wetness.mat b/examples/assets/wetness.mat new file mode 100644 index 000000000..d87f740ac --- /dev/null +++ b/examples/assets/wetness.mat @@ -0,0 +1,68 @@ +material { + name : Wetness, + shadingModel : unlit, + requires : [ position ], + variables : [ worldPos ], + parameters : [ + { type : float, name : time }, + { type : float, name : rainAmount } + ], + culling : none, + variantFilter : [ skinning, shadowReceiver, vsm ] +} + +vertex { + void materialVertex(inout MaterialVertexInputs material) { + material.worldPos.xyz = material.worldPosition.xyz; + } +} + +fragment { + float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } + float noise2(vec2 p) { + vec2 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1,0)), f.x), + mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), f.x), f.y); + } + float fbm(vec2 p) { + float v = 0.0; + v += noise2(p) * .52; p = p * 2.03 + 7.1; + v += noise2(p) * .27; p = p * 2.01 + 3.7; + v += noise2(p) * .14; p *= 2.0; + v += noise2(p) * .07; + return v; + } + float drop(vec2 p, float t, float seed) { + vec2 cell = floor(p); + vec2 center = vec2(hash(cell + seed), hash(cell + seed + 17.3)) - .5; + float age = fract(t * (.42 + hash(cell + 9.2) * .22) + hash(cell + seed * 2.0)); + float radius = age * .48; + float d = length(fract(p) - .5 - center * .55); + float ring = exp(-abs(d - radius) * 105.0) * smoothstep(1.0, .68, age); + return ring * step(.62, hash(cell + 41.0)); + } + void material(inout MaterialInputs material) { + prepareMaterial(material); + vec2 p = variable_worldPos.xz; + float macro = fbm(p * .34 + 8.0); + float puddle = smoothstep(.48, .61, macro + .08 * noise2(p * 2.7)); + float rain = drop(p * 1.9, materialParams.time, 1.0) + + drop(p * 2.8 + 4.2, materialParams.time * 1.17, 5.0) + + drop(p * 4.1 - 7.1, materialParams.time * .83, 11.0); + float grit = noise2(p * 22.0) * noise2(p * 8.0 + 3.0); + vec3 dry = mix(vec3(.008, .010, .013), vec3(.022, .018, .013), grit); + vec3 wet = mix(vec3(.0025, .004, .006), vec3(.008, .018, .024), macro); + vec3 v = normalize(getWorldCameraPosition() - variable_worldPos.xyz); + float grazing = pow(1.0 - max(v.y, 0.0), 3.2); + vec2 reflectedBand = normalize(vec2(-.55, .83)); + float streak = pow(max(dot(normalize(p - getWorldCameraPosition().xz), reflectedBand), 0.0), 18.0); + float pooled = puddle * materialParams.rainAmount; + vec3 color = mix(dry, wet, pooled); + color += vec3(.045, .12, .19) * pooled * (grazing * .8 + streak * .42); + color += vec3(.3, .62, .85) * rain * pooled * (.18 + grazing * .7); + float micro = pow(noise2(p * 31.0 + materialParams.time * .05), 16.0); + color += vec3(.34, .5, .62) * micro * pooled * grazing * .18; + material.baseColor = vec4(color, 1.0); + } +} diff --git a/examples/dart/examples_lib/lib/examples_lib.dart b/examples/dart/examples_lib/lib/examples_lib.dart index 3f5b09855..000d9fdea 100644 --- a/examples/dart/examples_lib/lib/examples_lib.dart +++ b/examples/dart/examples_lib/lib/examples_lib.dart @@ -12,6 +12,25 @@ export 'src/registry.dart'; export 'src/bone_animation.dart'; export 'src/camera_basics.dart'; export 'src/custom_geometry.dart'; +export 'src/game_effects_shared.dart'; +export 'src/game_effects_hit_flash.dart'; +export 'src/game_effects_hologram.dart'; +export 'src/game_effects_force_field.dart'; +export 'src/game_effects_dissolve_burn.dart'; +export 'src/game_effects_water.dart'; +export 'src/game_effects_smoke.dart'; +export 'src/game_effects_fire.dart'; +export 'src/game_effects_lava.dart'; +export 'src/game_effects_shockwave.dart'; +export 'src/game_effects_shore_waves.dart'; +export 'src/game_effects_wetness.dart'; +export 'src/game_effects_crystal_ice.dart'; +export 'src/game_effects_snow_accumulation.dart'; +export 'src/game_effects_damage_decals.dart'; +export 'src/game_effects_portal_rift.dart'; +export 'src/game_effects_electricity.dart'; +export 'src/game_effects_invisibility_cloak.dart'; +export 'src/game_effects_energy_weapon.dart'; export 'src/gltf_animation.dart'; export 'src/gizmo_basics.dart'; export 'src/headless_capture.dart'; diff --git a/examples/dart/examples_lib/lib/src/game_effects_crystal_ice.dart b/examples/dart/examples_lib/lib/src/game_effects_crystal_ice.dart new file mode 100644 index 000000000..fa3141f6e --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_crystal_ice.dart @@ -0,0 +1,72 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Faceted ice with spectral edge separation, animated inner volume, and +/// emissive branching fissures. A coarse hero shell makes its planes readable. +Future setupCrystalIce( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100, + aspect: 1, + focalLength: 34, + ); + await camera.lookAt(Vector3(2.25, 1.35, 3.4), focus: Vector3(0, 0.18, 0)); + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.24); + + final crystal = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: 'crystal_ice', + ); + await crystal.setParameterFloat('time', 1.8); + await crystal.setParameterFloat4('tint', 0.06, 0.42, 0.82, 0.92); + final shardGeometry = crystalShard(radius: 0.34, length: 1.0); + final shardTransforms = [ + Matrix4.translation(Vector3(0, -1.12, 0)) * + Matrix4.rotationY(0.32) * + Matrix4.diagonal3(Vector3(0.88, 2.35, 0.88)), + Matrix4.translation(Vector3(-0.48, -1.05, 0.05)) * + Matrix4.rotationZ(-0.26) * + Matrix4.rotationY(-0.42) * + Matrix4.diagonal3(Vector3(0.66, 1.58, 0.66)), + Matrix4.translation(Vector3(0.47, -1.05, 0.08)) * + Matrix4.rotationZ(0.31) * + Matrix4.rotationY(0.77) * + Matrix4.diagonal3(Vector3(0.61, 1.48, 0.61)), + Matrix4.translation(Vector3(-0.2, -1.08, 0.38)) * + Matrix4.rotationX(-0.23) * + Matrix4.diagonal3(Vector3(0.48, 1.22, 0.48)), + Matrix4.translation(Vector3(0.22, -1.08, -0.32)) * + Matrix4.rotationX(0.24) * + Matrix4.diagonal3(Vector3(0.44, 1.12, 0.44)), + ]; + for (final transform in shardTransforms) { + final shard = await viewer.createGeometry( + shardGeometry, + materialInstances: [crystal], + ); + await shard.setTransform(transform); + } + + final groundMaterial = await viewer.app.createUbershaderMaterial(); + await groundMaterial.setBaseColorFactor(0.018, 0.028, 0.055, 1.0); + await groundMaterial.setMetallicFactor(0.15); + await groundMaterial.setRoughnessFactor(0.22); + final ground = await viewer.createGeometry( + GeometryUtils.plane(width: 7, height: 7), + materialInstances: [groundMaterial.materialInstance], + ); + await ground.setTransform(Matrix4.translation(Vector3(0, -1.26, 0))); + await viewer.addDirectLight( + DirectLight.sun(direction: Vector3(-0.5, -0.7, -0.45), intensity: 80000), + ); + effectAnimators.add((t) async { + await crystal.setParameterFloat('time', t); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_damage_decals.dart b/examples/dart/examples_lib/lib/src/game_effects_damage_decals.dart new file mode 100644 index 000000000..1b33e4a7a --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_damage_decals.dart @@ -0,0 +1,36 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Layered impact decals: irregular holes, beveled hot rims, radial fracture +/// lines, soot falloff, and independent thermal decay for each strike. +Future setupDamageDecals( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100, + aspect: 1, + focalLength: 38, + ); + await camera.lookAt(Vector3(0, 0.05, 4.0), focus: Vector3(0, 0, 0)); + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.28); + final decals = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: 'damage_decals', + ); + await decals.setParameterFloat('time', 2.05); + final wall = await viewer.createGeometry( + subdividedPlane( + width: 3.8, depth: 2.8, subdivisionsX: 32, subdivisionsZ: 24), + materialInstances: [decals], + ); + await wall.setTransform(Matrix4.rotationX(1.5707963267948966)); + effectAnimators.add((t) async { + await decals.setParameterFloat('time', t % 3.2); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_dissolve_burn.dart b/examples/dart/examples_lib/lib/src/game_effects_dissolve_burn.dart new file mode 100644 index 000000000..434346cbd --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_dissolve_burn.dart @@ -0,0 +1,52 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Dissolve/burn: a noise threshold eats the mesh away while the receding +/// front burns - white-hot at the very edge through orange to deep red, +/// flickering like combustion, with ember sparks and a charring gradient +/// ahead of the front. `threshold` 0 = intact, 1 = fully dissolved. Opaque +/// blending with `discard` keeps depth-writing correct, and the noise is +/// sampled in object space so the burn is pinned to the mesh. +Future setupDissolveBurn( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100.0, + aspect: 1.0, + focalLength: 28.0, + ); + await camera.lookAt(Vector3(1.4, 0.9, 1.4), focus: Vector3(0, 0, 0)); + + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.32); + + final asset = + await viewer.loadGltf("$assetsDir/FlightHelmet/FlightHelmet.gltf"); + await asset.transformToUnitCube(); + + final dissolve = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "dissolve_burn", + ); + await dissolve.setParameterFloat4("baseColor", 0.10, 0.075, 0.06, 1.0); + await dissolve.setParameterFloat4("edgeColor", 1.0, 0.45, 0.1, 1.0); + await dissolve.setParameterFloat("threshold", 0.5); + await dissolve.setParameterFloat("edgeWidth", 0.065); + await dissolve.setParameterFloat("edgeIntensity", 1.35); + await dissolve.setParameterFloat("noiseScale", 3.4); + await dissolve.setParameterFloat("time", 1.2); + + await asset.setMaterialInstanceForAll(dissolve); + + // One full burn per 3.5s cycle, repeating. + effectAnimators.add((t) async { + final cycle = t % 3.5; + await dissolve.setParameterFloat("threshold", cycle / 3.5 * 0.95); + await dissolve.setParameterFloat("time", t); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_electricity.dart b/examples/dart/examples_lib/lib/src/game_effects_electricity.dart new file mode 100644 index 000000000..b3e85dc8a --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_electricity.dart @@ -0,0 +1,37 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// One-draw procedural lightning: coherent stepped trunk, seeded side forks, +/// sub-frame path regeneration, HDR core, and a soft ionized envelope. +Future setupElectricity( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100, + aspect: 1, + focalLength: 38, + ); + await camera.lookAt(Vector3(0, 0, 4.65), focus: Vector3(0, 0, 0)); + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.68); + const segments = 64; + final electricity = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: 'electricity', + ); + await electricity.setParameterFloat('time', 1.7); + await electricity.setParameterFloat('segmentCount', segments.toDouble()); + await viewer.createGeometry( + dummyBillboardQuads(segments), + materialInstances: [electricity], + ); + + effectAnimators.add((t) async { + await electricity.setParameterFloat('time', t); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_energy_weapon.dart b/examples/dart/examples_lib/lib/src/game_effects_energy_weapon.dart new file mode 100644 index 000000000..9f6841198 --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_energy_weapon.dart @@ -0,0 +1,50 @@ +import 'dart:math' as math; + +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Coordinated energy-weapon suite: pre-charge orb, turbulent beam envelope, +/// traveling core pulses, muzzle bloom, and a delayed expanding impact shell. +Future setupEnergyWeapon( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100, + aspect: 1, + focalLength: 38, + ); + await camera.lookAt(Vector3(0, 0.15, 5.3), focus: Vector3(0, 0, 0)); + await viewer.view.setFrustumCullingEnabled(false); + await viewer.loadIbl('$assetsDir/default_env_ibl.ktx'); + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.65); + + final beam = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: 'energy_weapon', + ); + await beam.setParameterFloat('time', 1.2); + await beam.setParameterFloat('mode', 0.0); + await beam.setParameterFloat('phase', 0.8); + final beamEntity = await viewer.createGeometry( + GeometryUtils.plane(width: 4.7, height: 1.35), + materialInstances: [beam], + ); + await beamEntity.setTransform( + Matrix4.translation(Vector3(0, 0, 0)) * + Matrix4.rotationX(1.5707963267948966), + ); + effectAnimators.add((t) async { + final cycle = t % 2.6; + final fire = cycle > 0.58 && cycle < 1.38 + ? math.sin((cycle - 0.58) / 0.8 * math.pi) + : 0.0; + await beam.setParameterFloat('time', t); + await beam.setParameterFloat('phase', fire); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_fire.dart b/examples/dart/examples_lib/lib/src/game_effects_fire.dart new file mode 100644 index 000000000..ed757e0d5 --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_fire.dart @@ -0,0 +1,92 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// GPU fire: camera-facing flame tongues and ember sparks generated in one +/// draw, backed by a second turbulent draw for the dark smoke cap. The fragment +/// shader scrolls domain-warped noise downward so its features lick +/// upward, shapes each tongue with a height-tapered mask, and colors it +/// through a blackbody ramp (white -> yellow -> orange -> red). Embers +/// rise from the flame, wobble, and fade from white-hot to red. +Future setupFire( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100.0, + aspect: 1.0, + focalLength: 28.0, + ); + await camera.lookAt(Vector3(0, 0.75, 3.0), focus: Vector3(0, 0.55, 0)); + + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.48); + + const flameCount = 12; + const emberCount = 36; + + final fire = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "fire", + ); + await fire.setParameterFloat("time", 4.6); + await fire.setParameterFloat("flameCount", flameCount.toDouble()); + await fire.setParameterFloat("emberCount", emberCount.toDouble()); + await fire.setParameterFloat("flameHeight", 1.12); + await fire.setParameterFloat("flameWidth", 0.27); + await fire.setParameterFloat("noiseScale", 3.4); + await fire.setParameterFloat("scrollSpeed", 2.5); + await fire.setParameterFloat("windLean", 0.22); + await fire.setParameterFloat("emberLifetime", 1.9); + + await viewer.createGeometry( + dummyBillboardQuads(flameCount + emberCount), + materialInstances: [fire], + ); + + final ground = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "fire_ground", + ); + await ground.setParameterFloat("time", 4.6); + final emberBed = await viewer.createGeometry( + GeometryUtils.plane(width: 1.5, height: 1.5), + materialInstances: [ground], + ); + await emberBed.setTransform(Matrix4.translation(Vector3(0, -0.015, 0))); + + // Fire without combustion smoke reads as a stylized sprite stack. A + // compact, dark plume merges the individual tongues into one volume and + // gives the effect a believable thermal lifecycle. + const smokeCount = 24; + final smoke = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "smoke", + ); + await smoke.setParameterFloat4("baseColor", 0.07, 0.06, 0.065, 1.0); + await smoke.setParameterFloat("time", 4.6); + await smoke.setParameterFloat("puffCount", smokeCount.toDouble()); + await smoke.setParameterFloat("riseSpeed", 0.31); + await smoke.setParameterFloat("expandSpeed", 0.075); + await smoke.setParameterFloat("swirlAmount", 1.05); + await smoke.setParameterFloat("baseSize", 0.13); + await smoke.setParameterFloat("noiseScale", 3.8); + await smoke.setParameterFloat("lifetime", 4.0); + await smoke.setParameterFloat("originHeight", 0.52); + await smoke.setParameterFloat("opacity", 0.32); + await viewer.createGeometry( + dummyBillboardQuads(smokeCount), + materialInstances: [smoke], + ); + + effectAnimators.add((t) async { + await fire.setParameterFloat("time", t); + await smoke.setParameterFloat("time", t); + await ground.setParameterFloat("time", t); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_force_field.dart b/examples/dart/examples_lib/lib/src/game_effects_force_field.dart new file mode 100644 index 000000000..13c13567d --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_force_field.dart @@ -0,0 +1,84 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Force-field bubble: an additive-blended sphere with a fresnel silhouette +/// (plus a hot rim lip), a wobbling hexagonal energy lattice with per-cell +/// power flow, and expanding impact ripples from periodic hits. An animated +/// crystalline energy core sits inside as the shield generator. +Future setupForceField( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100.0, + aspect: 1.0, + focalLength: 28.0, + ); + await camera.lookAt(Vector3(0, 0.7, 3.8), focus: Vector3(0, 0.05, 0)); + + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.34); + + final coreMaterial = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "force_core", + ); + await coreMaterial.setParameterFloat4("baseColor", 0.18, 0.56, 1.0, 1.0); + await coreMaterial.setParameterFloat("time", 2.25); + final core = await viewer.createGeometry( + GeometryUtils.sphere(latitudeBands: 6, longitudeBands: 8), + materialInstances: [coreMaterial], + ); + + final field = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "force_field", + ); + await field.setParameterFloat4("baseColor", 0.30, 0.55, 1.0, 1.0); + await field.setParameterFloat("time", 2.25); + await field.setParameterFloat("fresnelPower", 2.2); + await field.setParameterFloat("hexScale", 16.0); + await field.setParameterFloat("hexStrength", 1.25); + await field.setParameterFloat3("hitDirection", 0.4, 0.25, 0.88); + await field.setParameterFloat("hitAge", 0.35); + + final bubble = await viewer.createGeometry( + GeometryUtils.sphere(latitudeBands: 48, longitudeBands: 64), + materialInstances: [field], + ); + // GeometryUtils.sphere has radius 1.0; scale to a radius-1.2 bubble. + await bubble + .setTransform(Matrix4.identity()..scaleByVector3(Vector3.all(1.2))); + + // A new hit every 1.9s from a rotating ring of directions (biased toward + // the camera so ripples stay visible), with the ripple age driven from + // wall-clock time. + const hitPeriod = 1.9; + final hitDirections = [ + Vector3(0.45, 0.2, 0.87), + Vector3(-0.6, 0.5, 0.62), + Vector3(0.1, -0.85, 0.5), + Vector3(0.85, -0.1, 0.52), + ]; + effectAnimators.add((t) async { + // Derive the event entirely from t. Besides being deterministic for + // video, this makes a one-shot golden-time capture land mid-ripple + // instead of treating its first animator call as a brand-new hit. + final hitIndex = (t / hitPeriod).floor() % hitDirections.length; + final d = hitDirections[hitIndex]; + await field.setParameterFloat3("hitDirection", d.x, d.y, d.z); + await field.setParameterFloat("hitAge", t % hitPeriod); + await field.setParameterFloat("time", t); + await coreMaterial.setParameterFloat("time", t); + await core.setTransform( + Matrix4.rotationY(t * 0.72) * + Matrix4.rotationX(t * 0.43) * + Matrix4.diagonal3(Vector3(0.29, 0.38, 0.29)), + ); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_hit_flash.dart b/examples/dart/examples_lib/lib/src/game_effects_hit_flash.dart new file mode 100644 index 000000000..bed939391 --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_hit_flash.dart @@ -0,0 +1,95 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Hit flash: an impact ring expands from the hit point across the mesh +/// while a rim-weighted body flash decays - the classic "I got hit" read. +/// Driven by `progress` (0 = impact instant, 1 = finished) and `hitPoint` +/// (world space). Starts white-hot and settles into `flashColor`. +/// +/// The flash is a temporary material-instance swap: snapshot the asset's +/// original instances, swap in the flash instance, animate `progress` +/// 0 -> 1, then restore. For the headless still the swap is left at +/// mid-flash with the ring mid-expansion. +Future setupHitFlash( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100.0, + aspect: 1.0, + focalLength: 28.0, + ); + await camera.lookAt(Vector3(0, 0.1, 1.6), focus: Vector3(0, 0, 0)); + + // The flash swaps out every material on the asset, but between flashes + // the normal PBR look should show - so light the scene and keep the + // background dark for the additive blend. A dim directional light only + // (no IBL), tuned so the resting PBR helmet sits well below saturation: + // the additive flash needs that headroom, or the hotspot and shockwave + // ring drown in an already-bright surface. + await viewer.addDirectLight(DirectLight.sun( + direction: Vector3(0, -1, -0.4), + intensity: 4200, + castShadows: false, + )); + await viewer.addDirectLight( + DirectLight.point( + color: const LinearColor(0.45, 0.62, 1.0), + intensity: 90000, + falloffRadius: 4.0, + position: Vector3(-1.4, 1.1, 2.2), + castShadows: false, + ), + ); + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.32); + + // Keep the original PBR asset present throughout the hit. A second, + // coincident renderable carries the additive effect as a true overlay; + // replacing the base materials made the resting model disappear and the + // flash read like an x-ray material swap. + final asset = + await viewer.loadGltf("$assetsDir/FlightHelmet/FlightHelmet.gltf"); + await asset.transformToUnitCube(); + final flashOverlay = await viewer.loadGltf( + "$assetsDir/FlightHelmet/FlightHelmet.gltf", + ); + await flashOverlay.transformToUnitCube(); + + final flash = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "hit_flash", + ); + await flash.setParameterFloat4("flashColor", 1.0, 0.36, 0.1, 1.0); + await flash.setParameterFloat3("hitPoint", 0.05, 0.28, 0.42); + await flash.setParameterFloat("progress", 0.33); + await flash.setParameterFloat("normalOffset", 0.006); + + await flashOverlay.setMaterialInstanceForAll(flash); + + // A hit every 2.4s: animate the additive overlay 0 -> 1 over 0.55s, + // rotating through impact points on the camera-facing side. Deriving all + // state from t keeps stills, video frames, and live playback identical. + final hitPoints = [ + Vector3(0.05, 0.28, 0.42), + Vector3(-0.18, 0.05, 0.38), + Vector3(0.12, -0.22, 0.30), + ]; + const flashDuration = 0.55; + const hitPeriod = 2.4; + effectAnimators.add((t) async { + final cycle = t % hitPeriod; + final hitIndex = (t / hitPeriod).floor() % hitPoints.length; + final p = hitPoints[hitIndex]; + await flash.setParameterFloat3("hitPoint", p.x, p.y, p.z); + if (cycle < flashDuration) { + await flash.setParameterFloat("progress", cycle / flashDuration); + } else { + await flash.setParameterFloat("progress", 1.0); + } + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_hologram.dart b/examples/dart/examples_lib/lib/src/game_effects_hologram.dart new file mode 100644 index 000000000..c43fd6b0b --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_hologram.dart @@ -0,0 +1,80 @@ +import 'dart:math'; + +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Hologram projection: the whole mesh renders as a translucent cyan shell +/// with a fresnel rim, fine upward-sweeping scanlines, a bright scanning +/// band, gated glitch shear with chromatic splitting, and flicker. +/// Unlit + transparent, so no lights are needed. +Future setupHologram( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100.0, + aspect: 1.0, + focalLength: 28.0, + ); + // The source glTF's hidden display floor still contributes to its bounds, + // so frame the drone directly instead of inheriting that oversized stage. + await camera.lookAt(Vector3(0.82, 0.54, 0.82), focus: Vector3(0, 0, 0)); + + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.28); + + final asset = await viewer.loadGltf("$assetsDir/BusterDrone/scene.gltf"); + await asset.transformToUnitCube(); + + final hologram = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "hologram", + ); + await hologram.setParameterFloat4("tintColor", 0.20, 0.85, 1.0, 1.0); + await hologram.setParameterFloat("time", 2.2); + await hologram.setParameterFloat("fresnelPower", 2.5); + await hologram.setParameterFloat("fresnelStrength", 1.35); + await hologram.setParameterFloat("scanlineCount", 70.0); + await hologram.setParameterFloat("scanlineSpeed", 4.0); + await hologram.setParameterFloat("glitchAmount", 0.09); + + // The source asset includes a large display floor (`Scheibe_Boden_0`). + // Hide it so the projection reads as a floating subject rather than a + // glowing or black rectangular stage. + final displayFloor = await asset.getChildEntity("Scheibe_Boden_0"); + if (displayFloor != null) { + await asset.setVisibilityLayer(displayFloor, VisibilityLayers.LAYER_3); + await viewer.view.setLayerVisibility(VisibilityLayers.LAYER_3, false); + } + await asset.setMaterialInstanceForAll(hologram); + + // A hologram needs a visible emitter to sell the projection, not just a + // cyan replacement material. This animated reticle anchors the drone in + // the scene and adds a rotating acquisition sweep beneath it. + final projector = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "hologram_projector", + ); + await projector.setParameterFloat4("tintColor", 0.12, 0.78, 1.0, 1.0); + await projector.setParameterFloat("time", 2.2); + final emitter = await viewer.createGeometry( + GeometryUtils.plane(width: 1.25, height: 1.25), + materialInstances: [projector], + ); + await emitter.setTransform(Matrix4.translation(Vector3(0, -0.56, 0))); + + effectAnimators.add((t) async { + await hologram.setParameterFloat("time", t); + await projector.setParameterFloat("time", t); + final orbit = 0.08 * sin(t * 0.42); + await camera.lookAt( + Vector3(0.82 + orbit, 0.54 + 0.025 * sin(t * 0.7), 0.82 - orbit), + focus: Vector3(0, -0.02, 0), + ); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_invisibility_cloak.dart b/examples/dart/examples_lib/lib/src/game_effects_invisibility_cloak.dart new file mode 100644 index 000000000..24ccf8c95 --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_invisibility_cloak.dart @@ -0,0 +1,40 @@ +import 'dart:math' as math; + +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Active camouflage with a restrained near-invisible body, chromatic fresnel +/// silhouette, local refraction shimmer, scan faults, and periodic disruption. +Future setupInvisibilityCloak( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100, + aspect: 1, + focalLength: 31, + ); + await camera.lookAt(Vector3(1.55, 0.7, 2.35), focus: Vector3(0, 0, 0)); + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.24); + final asset = + await viewer.loadGltf('$assetsDir/FlightHelmet/FlightHelmet.gltf'); + await asset.transformToUnitCube(); + final cloak = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: 'invisibility_cloak', + ); + await cloak.setParameterFloat('time', 2.15); + await cloak.setParameterFloat('disruption', 0.55); + await asset.setMaterialInstanceForAll(cloak); + effectAnimators.add((t) async { + final disruption = + math.pow(math.max(0.0, math.sin(t * 1.42)), 12).toDouble(); + await cloak.setParameterFloat('time', t); + await cloak.setParameterFloat('disruption', 0.18 + disruption * 0.82); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_lava.dart b/examples/dart/examples_lib/lib/src/game_effects_lava.dart new file mode 100644 index 000000000..cb4d13920 --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_lava.dart @@ -0,0 +1,46 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Lava field: a vertex-displaced crust of slow sine lumps over a molten +/// interior. The fragment shader drifts a domain-warped fbm crust field; +/// where it dips low, glowing cracks show through (an inverted dissolve) +/// with a fast-flowing interior texture and a red-orange-yellow ramp. +Future setupLava( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100.0, + aspect: 1.0, + focalLength: 28.0, + ); + await camera.lookAt(Vector3(0, 2.0, 4.6), focus: Vector3(0, -0.3, 0)); + + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.38); + + final lava = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "lava", + ); + await lava.setParameterFloat("time", 3.0); + await lava.setParameterFloat("glowIntensity", 1.12); + await lava.setParameterFloat("crustScale", 1.0); + await lava.setParameterFloat("flowSpeed", 0.5); + await lava.setParameterFloat("swellHeight", 0.14); + + final surface = await viewer.createGeometry( + subdividedPlane( + width: 16.0, depth: 16.0, subdivisionsX: 176, subdivisionsZ: 176), + materialInstances: [lava], + ); + await surface.setTransform(Matrix4.translation(Vector3(0, 0, 0))); + + effectAnimators.add((t) async { + await lava.setParameterFloat("time", t); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_portal_rift.dart b/examples/dart/examples_lib/lib/src/game_effects_portal_rift.dart new file mode 100644 index 000000000..83fc7f254 --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_portal_rift.dart @@ -0,0 +1,46 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Deep-space portal with rotating tunnel parallax, opposing spiral flow, +/// star motes, a noisy high-energy rim, and a physically staged open/close. +Future setupPortalRift( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100, + aspect: 1, + focalLength: 36, + ); + await camera.lookAt(Vector3(0, 0, 3.15), focus: Vector3(0, 0, 0)); + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.34); + final portal = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: 'portal_rift', + ); + await portal.setParameterFloat('time', 2.2); + await portal.setParameterFloat('openAmount', 1.0); + final rift = await viewer.createGeometry( + GeometryUtils.plane(width: 3.1, height: 3.1), + materialInstances: [portal], + ); + await rift.setTransform( + Matrix4.rotationX(1.5707963267948966) * + Matrix4.diagonal3(Vector3(0.82, 1.0, 1.18)), + ); + effectAnimators.add((t) async { + final cycle = t % 5.0; + final open = cycle < 0.8 + ? cycle / 0.8 + : cycle > 4.25 + ? (5.0 - cycle) / 0.75 + : 1.0; + await portal.setParameterFloat('time', t); + await portal.setParameterFloat('openAmount', open); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_shared.dart b/examples/dart/examples_lib/lib/src/game_effects_shared.dart new file mode 100644 index 000000000..1320b949a --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_shared.dart @@ -0,0 +1,216 @@ +import 'dart:math'; + +import 'package:thermion_dart/thermion_dart.dart'; + +/// Flat XZ grid with (subdivisionsX+1)*(subdivisionsZ+1) vertices, normals +/// up, UVs across [0,1]. GeometryUtils.plane is only 4 vertices, far too +/// coarse for vertex-displaced surfaces like water. +Geometry subdividedPlane({ + double width = 10.0, + double depth = 10.0, + int subdivisionsX = 64, + int subdivisionsZ = 64, +}) { + final vertCount = (subdivisionsX + 1) * (subdivisionsZ + 1); + final vertices = Float32List(vertCount * 3); + final normals = Float32List(vertCount * 3); + final uvs = Float32List(vertCount * 2); + final indices = []; + + for (int z = 0; z <= subdivisionsZ; z++) { + for (int x = 0; x <= subdivisionsX; x++) { + final i = z * (subdivisionsX + 1) + x; + final u = x / subdivisionsX; + final v = z / subdivisionsZ; + vertices[i * 3] = (u - 0.5) * width; + vertices[i * 3 + 1] = 0.0; + vertices[i * 3 + 2] = (v - 0.5) * depth; + normals[i * 3] = 0.0; + normals[i * 3 + 1] = 1.0; + normals[i * 3 + 2] = 0.0; + uvs[i * 2] = u; + uvs[i * 2 + 1] = v; + } + } + + for (int z = 0; z < subdivisionsZ; z++) { + for (int x = 0; x < subdivisionsX; x++) { + final tl = z * (subdivisionsX + 1) + x; + final tr = tl + 1; + final bl = tl + (subdivisionsX + 1); + final br = bl + 1; + indices.addAll([tl, bl, tr, tr, bl, br]); + } + } + + return Geometry( + vertices, + indices, + normals: normals, + uvs: uvs, + ); +} + +/// Flat-shaded six-sided crystal with a long prismatic body and a pointed +/// crown. Vertices are duplicated per face so the facets stay hard under any +/// material, unlike a smooth cone whose silhouette reads as a stalagmite. +Geometry crystalShard({ + double radius = 0.32, + double length = 1.0, + double shoulder = 0.72, +}) { + final vertices = []; + final normals = []; + final indices = []; + + void face(List points) { + final base = vertices.length ~/ 3; + final edgeA = points[1] - points[0]; + final edgeB = points[2] - points[0]; + final normal = edgeA.cross(edgeB)..normalize(); + for (final point in points) { + vertices.addAll([point.x, point.y, point.z]); + normals.addAll([normal.x, normal.y, normal.z]); + } + if (points.length == 3) { + indices.addAll([base, base + 1, base + 2]); + } else { + indices.addAll([base, base + 1, base + 2, base, base + 2, base + 3]); + } + } + + final bottom = []; + final top = []; + for (var i = 0; i < 6; i++) { + final angle = i * 1.0471975511965976; + bottom.add(Vector3(radius * cos(angle), 0, radius * sin(angle))); + top.add( + Vector3( + radius * 0.82 * cos(angle), + length * shoulder, + radius * 0.82 * sin(angle), + ), + ); + } + final tip = Vector3(0, length, 0); + for (var i = 0; i < 6; i++) { + final next = (i + 1) % 6; + face([bottom[i], bottom[next], top[next], top[i]]); + face([top[i], top[next], tip]); + } + face([bottom[0], bottom[2], bottom[1]]); + face([bottom[0], bottom[3], bottom[2]]); + face([bottom[0], bottom[4], bottom[3]]); + face([bottom[0], bottom[5], bottom[4]]); + return Geometry( + Float32List.fromList(vertices), + indices, + normals: Float32List.fromList(normals), + ); +} + +/// Degenerate geometry for [quadCount] billboards fully generated in the +/// vertex shader (see smoke.mat): 6 vertices per puff keep the POSITION +/// attribute bound and indexed, while the shader derives real positions +/// from getVertexIndex(). Index type must stay UINT even though indices +/// are sequential - they address the vertex buffer directly. +/// +/// The vertices are NOT all-zero: a fully degenerate vertex buffer yields a +/// zero-size bounding box, which makes frustum culling of the renderable +/// unreliable. Each puff's 6 vertices therefore sit at a deterministic +/// point inside a small box around the origin - large enough for a valid +/// bounding volume, small enough that the vertex shader can treat its +/// displacement as purely additive (see the NOTE in smoke.mat: only +/// additive worldPosition displacements survive the pipeline). +Geometry dummyBillboardQuads(int quadCount) { + final vertCount = quadCount * 6; + final vertices = Float32List(vertCount * 3); + + double fract(double x) => x - x.floorToDouble(); + double hash(int n) { + var v = 0.0; + for (var i = 0; i < 8; i++) { + v = fract(v * 61.7 + n * 0.1031); + } + return v; + } + + for (var q = 0; q < quadCount; q++) { + final x = (hash(q * 3 + 1) - 0.5) * 0.7; + final y = hash(q * 3 + 2) * 0.5; + final z = (hash(q * 3 + 3) - 0.5) * 0.7; + for (var v = 0; v < 6; v++) { + final i = (q * 6 + v) * 3; + vertices[i] = x; + vertices[i + 1] = y; + vertices[i + 2] = z; + } + } + return Geometry( + vertices, + List.generate(vertCount, (i) => i), + ); +} + +/// Clock for driving effect animation. +/// +/// Two modes: +/// - [tick] from a `registerRequestFrameHook` callback in a live runner +/// (the headless runner's `capture()` bypasses hooks entirely). +/// - [setTime] to jump to a fixed "golden" time so still captures show the +/// effect at an interesting point in its animation. +class EffectClock { + final Stopwatch _sw = Stopwatch()..start(); + + double elapsedTime = 0.0; + + /// Call from a requestFrameHook to advance time from the wall clock. + void tick() { + elapsedTime = _sw.elapsedMilliseconds / 1000.0; + } + + /// Jump to a fixed time (for deterministic still captures). + void setTime(double t) { + elapsedTime = t; + } +} + +/// Time-driven animators registered by the game-effect setups. The headless +/// runner's `--video` mode passes wall-clock seconds; each closure maps t to +/// uniform updates on its own instances. (capture() bypasses requestFrame +/// hooks, so animation for video/stills is driven through these instead.) +final List Function(double t)> effectAnimators = []; + +/// Loads one of the game-effect materials from `examples/assets` and returns +/// a ready-to-use [MaterialInstance]. +Future loadEffectMaterial( + ThermionViewer viewer, { + required String assetsDir, + required String name, +}) async { + final Uint8List bytes = + await FilamentApp.instance!.loadResource("$assetsDir/$name.filamat"); + final material = await FilamentApp.instance!.createMaterial(bytes); + return await material.createInstance(); +} + +/// Near-black skybox for additive effects (smoke, force field, hit flash), +/// which wash out against a bright background. The engine's default exposure +/// lifts small linear values considerably, so these are deliberately tiny. +Future setDarkSkybox(ThermionViewer viewer) async { + await (await viewer.view.getScene()).setSkybox( + await FilamentApp.instance! + .createColoredSkybox(r: 0.004, g: 0.005, b: 0.012, a: 1.0), + ); +} + +/// Enables the post stack used by the presentation renders. In particular, +/// emissive game VFX need bloom to turn shader radiance into a perceptual +/// glow; without it even correct HDR values read like flat cutouts. +Future enableVfxPost( + ThermionViewer viewer, { + double bloomStrength = 0.3, +}) async { + await viewer.setPostProcessing(true); + await viewer.setBloom(true, bloomStrength); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_shockwave.dart b/examples/dart/examples_lib/lib/src/game_effects_shockwave.dart new file mode 100644 index 000000000..803d98073 --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_shockwave.dart @@ -0,0 +1,69 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Shockwave: an energy pulse every [period] seconds - an arc-broken ring +/// races out across the ground plane while a fresnel dome expands out of +/// the epicenter and fades. The dome's scale is driven from Dart (the +/// animator), its fade from the material's `age` uniform. +Future setupShockwave( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100.0, + aspect: 1.0, + focalLength: 28.0, + ); + await camera.lookAt(Vector3(0, 2.6, 5.2), focus: Vector3(0, 0.3, 0)); + + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.45); + + const period = 2.2; + const waveSpeed = 3.6; + + final ground = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "shockwave_ground", + ); + await ground.setParameterFloat("time", 0.0); + await ground.setParameterFloat("period", period); + await ground.setParameterFloat("waveSpeed", waveSpeed); + + final groundPlane = await viewer.createGeometry( + subdividedPlane( + width: 18.0, depth: 18.0, subdivisionsX: 4, subdivisionsZ: 4), + materialInstances: [ground], + ); + await groundPlane.setTransform(Matrix4.translation(Vector3(0, 0, 0))); + + final dome = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "shockwave_dome", + ); + await dome.setParameterFloat4("baseColor", 0.30, 0.75, 1.0, 1.0); + await dome.setParameterFloat("time", 0.0); + await dome.setParameterFloat("age", 0.0); + + final domeSphere = await viewer.createGeometry( + GeometryUtils.sphere(latitudeBands: 32, longitudeBands: 48), + materialInstances: [dome], + ); + + effectAnimators.add((t) async { + final age = t % period; + await ground.setParameterFloat("time", t); + await dome.setParameterFloat("time", t); + await dome.setParameterFloat("age", age); + // The dome expands with the ground ring's front and dissolves. + final radius = 0.15 + age * waveSpeed * 0.78; + await domeSphere.setTransform( + Matrix4.identity()..scaleByVector3(Vector3.all(radius)), + ); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_shore_waves.dart b/examples/dart/examples_lib/lib/src/game_effects_shore_waves.dart new file mode 100644 index 000000000..f0f8a6606 --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_shore_waves.dart @@ -0,0 +1,73 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Shore waves: swells shoal as they approach an analytic shoreline, then +/// break into a pulsing foam line that runs along the beach. Deep water +/// fades to turquoise shallows; past the shoreline the water melts into a +/// sand plane (same shoreline function) with a wet wash band. All from +/// world position - no depth texture needed since the scene geometry is +/// under our control. +Future setupShoreWaves( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100.0, + aspect: 1.0, + focalLength: 28.0, + ); + await camera.lookAt(Vector3(0, 4.5, -4.8), focus: Vector3(0, -0.2, 1.2)); + + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.08); + + // Sand beach behind the shoreline, just below the water plane. + final sand = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "sand", + ); + // Unlit opaque colors are strongly lifted by the headless exposure path; + // keep the source dark so the rendered beach remains warm tan, not white. + await sand.setParameterFloat4("sandColor", 0.085, 0.050, 0.022, 1.0); + await sand.setParameterFloat("time", 2.0); + final beach = await viewer.createGeometry( + subdividedPlane( + width: 20.0, depth: 16.0, subdivisionsX: 8, subdivisionsZ: 12), + materialInstances: [sand], + ); + await beach.setTransform(Matrix4.translation(Vector3(0, 0.08, 8.2))); + + final water = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "shore_waves", + ); + await water.setParameterFloat4("deepColor", 0.008, 0.07, 0.12, 1.0); + await water.setParameterFloat4("shallowColor", 0.025, 0.30, 0.34, 1.0); + await water.setParameterFloat4("skyColor", 0.075, 0.14, 0.22, 1.0); + await water.setParameterFloat4("foamColor", 0.40, 0.52, 0.58, 1.0); + await water.setParameterFloat3("sunDirection", -0.45, -0.35, -0.8); + await water.setParameterFloat("time", 2.0); + await water.setParameterFloat("waveHeight", 0.24); + await water.setParameterFloat("waveFrequency", 1.35); + await water.setParameterFloat("waveSpeed", 1.5); + await water.setParameterFloat("foamAmount", 0.72); + await water.setParameterFloat("detailStrength", 0.78); + + final surface = await viewer.createGeometry( + subdividedPlane( + width: 18.0, depth: 18.0, subdivisionsX: 150, subdivisionsZ: 180), + materialInstances: [water], + ); + await surface.setTransform(Matrix4.identity()); + + effectAnimators.add((t) async { + await water.setParameterFloat("time", t); + // The sand's swash line is phase-locked to the water's breaker pulse. + await sand.setParameterFloat("time", t); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_smoke.dart b/examples/dart/examples_lib/lib/src/game_effects_smoke.dart new file mode 100644 index 000000000..a09256484 --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_smoke.dart @@ -0,0 +1,53 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// GPU smoke: a single draw call of billboarded puffs generated entirely in +/// the vertex shader (one puff per 6 vertices of a degenerate mesh). Puffs +/// rise, stretch, spin and spiral outward while wind bends the plume; the +/// fragment shader shapes each quad with a soft radial falloff and +/// domain-warped fbm turbulence. +Future setupSmoke( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100.0, + aspect: 1.0, + focalLength: 28.0, + ); + await camera.lookAt(Vector3(0.35, 1.3, 3.6), focus: Vector3(0.3, 1.0, 0)); + + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.08); + + const puffCount = 64; + + final smoke = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "smoke", + ); + await smoke.setParameterFloat4("baseColor", 0.23, 0.25, 0.30, 1.0); + await smoke.setParameterFloat("time", 4.6); + await smoke.setParameterFloat("puffCount", puffCount.toDouble()); + await smoke.setParameterFloat("riseSpeed", 0.42); + await smoke.setParameterFloat("expandSpeed", 0.105); + await smoke.setParameterFloat("swirlAmount", 1.35); + await smoke.setParameterFloat("baseSize", 0.18); + await smoke.setParameterFloat("noiseScale", 3.4); + await smoke.setParameterFloat("lifetime", 5.2); + await smoke.setParameterFloat("originHeight", 0.0); + await smoke.setParameterFloat("opacity", 1.0); + + await viewer.createGeometry( + dummyBillboardQuads(puffCount), + materialInstances: [smoke], + ); + + effectAnimators.add((t) async { + await smoke.setParameterFloat("time", t); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_snow_accumulation.dart b/examples/dart/examples_lib/lib/src/game_effects_snow_accumulation.dart new file mode 100644 index 000000000..9c7c9876a --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_snow_accumulation.dart @@ -0,0 +1,44 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Directional snow loading across a complex mesh. Surface slope, height, +/// wind-scale noise, frost sparkle, and a moving snow line all contribute. +Future setupSnowAccumulation( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100, + aspect: 1, + focalLength: 32, + ); + await camera.lookAt(Vector3(1.65, 0.95, 2.15), focus: Vector3(0, 0.02, 0)); + await viewer.loadIbl('$assetsDir/default_env_ibl.ktx'); + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.16); + + final asset = + await viewer.loadGltf('$assetsDir/FlightHelmet/FlightHelmet.gltf'); + await asset.transformToUnitCube(); + final snow = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: 'snow_accumulation', + ); + await snow.setParameterFloat('time', 2.6); + await snow.setParameterFloat('accumulation', 0.72); + await asset.setMaterialInstanceForAll(snow); + await viewer.addDirectLight( + DirectLight.sun(direction: Vector3(-0.3, -0.82, -0.48), intensity: 90000), + ); + effectAnimators.add((t) async { + final cycle = t % 6.0; + final amount = + cycle < 3.0 ? 0.28 + cycle * 0.23 : 0.97 - (cycle - 3.0) * 0.23; + await snow.setParameterFloat('time', t); + await snow.setParameterFloat('accumulation', amount); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_water.dart b/examples/dart/examples_lib/lib/src/game_effects_water.dart new file mode 100644 index 000000000..50d05e33d --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_water.dart @@ -0,0 +1,55 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Animated water surface: five Gerstner waves displace a subdivided grid in +/// the vertex shader (with finite-difference normals and crest/chop foam +/// inputs), while the fragment shader adds two scrolled detail-normal layers, +/// Schlick fresnel deep/sky mixing, a backlit crest subsurface glow, dual-lobe +/// sun glitter, crest foam, and a distance haze that melts the plane edge. +Future setupWater( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100.0, + aspect: 1.0, + focalLength: 28.0, + ); + await camera.lookAt(Vector3(0, 1.8, 5.0), focus: Vector3(0, -0.1, 0)); + + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.10); + + final water = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: "water", + ); + await water.setParameterFloat4("deepColor", 0.008, 0.058, 0.090, 0.94); + await water.setParameterFloat4("skyColor", 0.36, 0.52, 0.72, 1.0); + await water.setParameterFloat4("foamColor", 0.94, 0.98, 1.0, 1.0); + await water.setParameterFloat3("sunDirection", -0.55, -0.35, -0.75); + await water.setParameterFloat("time", 1.7); + await water.setParameterFloat("waveHeight", 0.27); + await water.setParameterFloat("waveFrequency", 1.12); + await water.setParameterFloat("waveSpeed", 1.6); + await water.setParameterFloat("foamAmount", 0.72); + await water.setParameterFloat("specularPower", 520.0); + await water.setParameterFloat("specularIntensity", 2.5); + await water.setParameterFloat("detailStrength", 0.82); + await water.setParameterFloat("sssStrength", 0.9); + + final surface = await viewer.createGeometry( + subdividedPlane( + width: 24.0, depth: 24.0, subdivisionsX: 240, subdivisionsZ: 240), + materialInstances: [water], + ); + await surface.setTransform(Matrix4.translation(Vector3(0, 0, 0))); + + effectAnimators.add((t) async { + await water.setParameterFloat("time", t); + }); +} diff --git a/examples/dart/examples_lib/lib/src/game_effects_wetness.dart b/examples/dart/examples_lib/lib/src/game_effects_wetness.dart new file mode 100644 index 000000000..aa03fba29 --- /dev/null +++ b/examples/dart/examples_lib/lib/src/game_effects_wetness.dart @@ -0,0 +1,43 @@ +import 'package:thermion_dart/thermion_dart.dart'; + +import 'game_effects_shared.dart'; + +/// Rain-soaked ground with pooled clear coat, rough aggregate, animated +/// multi-scale drops, and an environment-driven grazing reflection. +Future setupWetness( + ThermionViewer viewer, { + required String assetsDir, +}) async { + final camera = await viewer.getActiveCamera(); + await camera.setLensProjection( + near: 0.1, + far: 100.0, + aspect: 1.0, + focalLength: 32.0, + ); + await camera.lookAt(Vector3(4.5, 2.15, 5.2), focus: Vector3(0, 0, -0.7)); + await viewer.loadIbl('$assetsDir/default_env_ibl.ktx'); + await setDarkSkybox(viewer); + await enableVfxPost(viewer, bloomStrength: 0.18); + + final wetness = await loadEffectMaterial( + viewer, + assetsDir: assetsDir, + name: 'wetness', + ); + await wetness.setParameterFloat('time', 2.35); + await wetness.setParameterFloat('rainAmount', 1.0); + await viewer.createGeometry( + subdividedPlane(width: 10, depth: 9, subdivisionsX: 72, subdivisionsZ: 72), + materialInstances: [wetness], + ); + await viewer.addDirectLight( + DirectLight.sun( + direction: Vector3(-0.45, -0.72, -0.53), + intensity: 65000, + ), + ); + effectAnimators.add((t) async { + await wetness.setParameterFloat('time', t); + }); +} diff --git a/examples/dart/examples_lib/lib/src/registry.dart b/examples/dart/examples_lib/lib/src/registry.dart index ba4c4f0c3..0528017be 100644 --- a/examples/dart/examples_lib/lib/src/registry.dart +++ b/examples/dart/examples_lib/lib/src/registry.dart @@ -28,6 +28,24 @@ import 'load_gltf.dart'; import 'load_via_assimp.dart'; import 'shadows.dart'; import 'skybox_and_background.dart'; +import 'game_effects_hit_flash.dart'; +import 'game_effects_hologram.dart'; +import 'game_effects_force_field.dart'; +import 'game_effects_dissolve_burn.dart'; +import 'game_effects_water.dart'; +import 'game_effects_smoke.dart'; +import 'game_effects_fire.dart'; +import 'game_effects_lava.dart'; +import 'game_effects_shockwave.dart'; +import 'game_effects_shore_waves.dart'; +import 'game_effects_wetness.dart'; +import 'game_effects_crystal_ice.dart'; +import 'game_effects_snow_accumulation.dart'; +import 'game_effects_damage_decals.dart'; +import 'game_effects_portal_rift.dart'; +import 'game_effects_electricity.dart'; +import 'game_effects_invisibility_cloak.dart'; +import 'game_effects_energy_weapon.dart'; /// A scene-setup function: configures a scene on a ready [ThermionViewer]. typedef ExampleSetup = Future Function( @@ -41,6 +59,24 @@ final Map registry = { 'bone_animation': setupBoneAnimation, 'camera_basics': setupCameraBasics, 'custom_geometry': setupCustomGeometry, + 'game_effects_hit_flash': setupHitFlash, + 'game_effects_hologram': setupHologram, + 'game_effects_force_field': setupForceField, + 'game_effects_dissolve_burn': setupDissolveBurn, + 'game_effects_water': setupWater, + 'game_effects_smoke': setupSmoke, + 'game_effects_fire': setupFire, + 'game_effects_lava': setupLava, + 'game_effects_shockwave': setupShockwave, + 'game_effects_shore_waves': setupShoreWaves, + 'game_effects_wetness': setupWetness, + 'game_effects_crystal_ice': setupCrystalIce, + 'game_effects_snow_accumulation': setupSnowAccumulation, + 'game_effects_damage_decals': setupDamageDecals, + 'game_effects_portal_rift': setupPortalRift, + 'game_effects_electricity': setupElectricity, + 'game_effects_invisibility_cloak': setupInvisibilityCloak, + 'game_effects_energy_weapon': setupEnergyWeapon, 'geometry_primitives': setupGeometryPrimitives, 'gizmo_basics': setupGizmoBasics, 'gltf_animation': setupGltfAnimation, diff --git a/examples/dart/headless_runner/bin/run_example.dart b/examples/dart/headless_runner/bin/run_example.dart index 9df98c7f2..a712fd6e0 100644 --- a/examples/dart/headless_runner/bin/run_example.dart +++ b/examples/dart/headless_runner/bin/run_example.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:isolate'; import 'package:thermion_dart/src/filament/src/implementation/ffi_filament_app.dart'; import 'package:thermion_dart/thermion_dart.dart'; @@ -7,19 +6,38 @@ import 'package:thermion_examples_lib/examples_lib.dart'; /// Renders a registered example headlessly and writes a PNG to output/. /// -/// dart run [width] [height] +/// dart run bin/run_example.dart [width] [height] [--time ] [--video [seconds] [fps]] /// /// must be a key in [registry]. Defaults to `load_gltf` at 512x512. +/// +/// Stills render at the animators' t=0 state by default; pass `--time` to +/// capture a specific point in the animation instead (the setups' golden +/// times are otherwise stomped by the animator call). +/// +/// With `--video`, advances the example's registered [effectAnimators] by +/// wall-clock seconds per frame, captures each frame to +/// output/_frames/, then encodes output/.mp4 with ffmpeg +/// (frames are left on disk if ffmpeg is unavailable). Future main(List args) async { final name = args.isNotEmpty ? args[0] : 'load_gltf'; final width = args.length > 1 ? int.parse(args[1]) : 512; final height = args.length > 2 ? int.parse(args[2]) : 512; + final videoIndex = args.indexOf('--video'); + final video = videoIndex >= 0; + final timeIndex = args.indexOf('--time'); + final stillTime = + timeIndex >= 0 && args.length > timeIndex + 1 ? double.parse(args[timeIndex + 1]) : 0.0; + final seconds = video && args.length > videoIndex + 1 + ? double.parse(args[videoIndex + 1]) + : 4.0; + final fps = video && args.length > videoIndex + 2 + ? int.parse(args[videoIndex + 2]) + : 30; final setup = registry[name]; if (setup == null) { stderr.writeln('Unknown example: $name'); stderr.writeln('Available: ${registry.keys.join(', ')}'); - Isolate.current.kill(); - return; + exit(1); } // Native file-based resource loader. assetsDir is repo-relative; the examples @@ -42,30 +60,73 @@ Future main(List args) async { await setup(viewer, assetsDir: 'file://${Directory.current.path}/../../assets'); - // Capture a single rendered frame and save it. Directory('output').createSync(recursive: true); - final pixelBuffers = await FilamentApp.instance!.capture( - swapChain, - view: viewer.view, - pixelDataFormat: PixelDataFormat.RGBA, - pixelDataType: PixelDataType.FLOAT, - render: true, - ); - final pixels = pixelBuffers.first.$2; - final png = await pixelBufferToPng( - pixels, - width, - height, - hasAlpha: true, - isFloat: true, - linearToSrgb: true, - ); - final outPath = 'output/$name.png'; - File(outPath).writeAsBytesSync(png); - stdout.writeln('Saved $outPath'); - await viewer.dispose(); - await FilamentApp.instance!.destroySwapChain(swapChain); - await FilamentApp.instance!.destroy(); - Isolate.current.kill(); + // Renders one frame with the animators advanced to [t] and returns PNG + // bytes. Parameters set before capture() persist on the instances, and + // capture() bypasses requestFrame hooks - so animation must be applied + // through the animators rather than frame hooks. + Future frameAt(double t) async { + for (final animate in effectAnimators) { + await animate(t); + } + final pixelBuffers = await FilamentApp.instance!.capture( + swapChain, + view: viewer.view, + pixelDataFormat: PixelDataFormat.RGBA, + pixelDataType: PixelDataType.FLOAT, + render: true, + ); + return pixelBufferToPng( + pixelBuffers.first.$2, + width, + height, + hasAlpha: true, + isFloat: true, + linearToSrgb: true, + ); + } + + if (!video) { + // Capture a single rendered frame and save it. + final png = await frameAt(stillTime); + final outPath = 'output/$name.png'; + File(outPath).writeAsBytesSync(png); + stdout.writeln('Saved $outPath'); + + // Exit immediately after the capture. Full engine teardown is unreliable + // here: destroying a material instance still assigned to a renderable + // deadlocks, and Filament panics when a material is destroyed while + // instances remain alive. The PNG is already on disk and the process is + // finished, so reclaiming engine resources buys nothing. + await stdout.flush(); + exit(0); + } + + final frameCount = (seconds * fps).round(); + final framesDir = Directory('output/${name}_frames') + ..createSync(recursive: true); + for (var i = 0; i < frameCount; i++) { + final png = await frameAt(i / fps); + File('${framesDir.path}/frame_${i.toString().padLeft(5, '0')}.png') + .writeAsBytesSync(png); + if (i % 10 == 0) { + stdout.writeln('frame $i/$frameCount'); + await stdout.flush(); + } + } + + final mp4Path = 'output/$name.mp4'; + final ffmpeg = await Process.start('ffmpeg', [ + '-y', + '-framerate', '$fps', + '-i', '${framesDir.path}/frame_%05d.png', + '-pix_fmt', 'yuv420p', + '-crf', '20', + mp4Path, + ]); + ffmpeg.stderr.transform(SystemEncoding().decoder).listen(stderr.write); + final code = await ffmpeg.exitCode; + await stdout.flush(); + exit(code == 0 ? 0 : 1); } diff --git a/game_effects_plan.md b/game_effects_plan.md new file mode 100644 index 000000000..5bf3e0891 --- /dev/null +++ b/game_effects_plan.md @@ -0,0 +1,107 @@ +# Game-Effect Shaders for Thermion (hit flash, dissolve, water, smoke, hologram, force field) + +## Context + +Thermion has no game-effect shaders — every custom material in the repo is editor/utility (grid, outline, wireframe, gizmo). We want visually appealing game VFX: **hit flash, dissolve/burn, water, smoke/fog, hologram, force field**. Decisions: + +- **Example-level only** — no core engine changes, no new public API. Materials ship as standalone `.filamat` assets, demoed from `examples/dart/examples_lib`. +- **Pure shaders** — no particle system (vendored Filament has none; smoke is GPU-billboard shader work). +- **macOS desktop (Metal) first**, verified via the headless runner. + +The full pipeline for this already exists and is proven by `proceduralquad`/`customattributes`/`viewspace`/`solidcolor`: author `.mat` → compile with `matc` → load bytes at runtime via `FilamentApp.createMaterial()` → drive uniforms via `MaterialInstance.setParameter*`. No runtime shader compilation exists, so matc is the iteration loop. + +## Verified mechanics the plan relies on + +| Mechanism | Where | Notes | +|---|---|---| +| `.mat` → `.filamat` | `materials/build.sh` `EXAMPLE_MATERIALS` array (line 44) | Compiles standalone all-backend `.filamat` into `examples/assets/` | +| Runtime material creation | `FilamentApp.createMaterial(Uint8List)` (interface `filament_app.dart:129`) | Accepts pre-compiled `.filamat` bytes | +| Asset loading in examples_lib | `FilamentApp.instance!.loadResource("$assetsDir/.filamat")` (interface line 45); runner wires it to file I/O | `assetsDir` = `file://…/examples/assets` | +| Uniform setting | `MaterialInstance.setParameterFloat/Float2/3/4/Int/Bool/Texture` (`material.dart`) | BlendingMode is baked in `.mat`; `setTransparencyMode` runtime-only | +| Per-frame time | `registerRequestFrameHook` (`ffi_filament_app.dart:773-810`) — hooks run at top of `render()` only | `capture()` bypasses hooks; `beforeRender` param is **not awaited** (line 984) → for captures set params *before* calling capture (they persist on the instance) | +| Headless verify | `examples/dart/headless_runner/bin/run_example.dart` — `dart run [w] [h]` → `output/.png` | One capture after `setup()` returns | +| Custom vertex attrs / vertex-generated quads | `examples/assets/proceduralquad.mat` (quad from `getVertexIndex()`), `customattributes.mat` (`requires: [custom0]`, `getCustom0()`) | Precedents for smoke billboards | +| Geometry | `viewer.createGeometry(Geometry, materialInstances:[...])`; `GeometryUtils` has NO subdivided plane (plane() = 4 verts) | Water grid helper written example-side | +| `ExampleSetup` typedef | `registry.dart:33`: `Future Function(ThermionViewer viewer, {required String assetsDir})` | | +| Windowed runners | `cli_windows` is Win32-only — unusable on macOS | Headless PNG loop is the macOS iteration vehicle | + +All noise is **procedural** (hash/value-noise fbm in GLSL) — no texture assets needed. Fresnel = manual `pow(1 - |dot(normal, viewDir)|, power)`; view dir from `getWorldCameraPosition() - getWorldPosition().xyz` (pattern: `materials/bone_overlay.mat`). Normals reach the fragment shader via `variables` (bone_overlay pattern); `createGeometry` auto-generates tangent quaternions when normals exist, so include normals in custom geometry. + +## New files + +**Material sources + compiled artifacts** (`examples/assets/`): `hit_flash`, `hologram`, `force_field`, `dissolve_burn`, `water`, `smoke` — each `.mat` (authored) + `.filamat` (committed, built). + +**Dart** (`examples/dart/examples_lib/lib/src/`): +- `game_effects_shared.dart` — `EffectClock` (Stopwatch-based; `tick()` for hooks, `setTime(t)` for golden stills), `subdividedPlane(width, depth, subX, subZ)` geometry builder, `dummyBillboardQuads(n)` (zeros vertices, indices 0..6n−1 — positions overridden in vertex shader; keep POSITION active per proceduralquad notes) +- `game_effects_hit_flash.dart`, `game_effects_hologram.dart`, `game_effects_force_field.dart`, `game_effects_dissolve_burn.dart`, `game_effects_water.dart`, `game_effects_smoke.dart` — one `setupX(viewer, {required assetsDir})` each + +**Modified**: +- `examples/dart/examples_lib/lib/src/registry.dart` — add `'hit_flash'`, `'hologram'`, `'force_field'`, `'dissolve_burn'`, `'water'`, `'smoke'` to `registry` +- `examples/dart/examples_lib/lib/examples_lib.dart` — export the 7 new files +- `materials/build.sh` line 44 — extend `EXAMPLE_MATERIALS` with the 6 names + +## Material designs + +| Material | Shading / blending | Key uniforms | Technique | +|---|---|---|---| +| **hit_flash** | unlit / additive, depthWrite off, culling none | `flashColor` f4, `progress` f, `flashDuration` f | Quadratic ease-out fade; flat bright tint (swap onto entity — that's the effect). Dart drives 0→1 then restores | +| **hologram** | unlit / transparent, depthWrite off | `tintColor` f4, `time`, `fresnelPower/Strength`, `scanlineCount/Speed/Width`, `glitchAmount` | Fresnel rim + world-Y sin scanlines + subtle vertex X-jitter + flicker; premultiplied out | +| **force_field** | unlit / additive, depthWrite off | `color` f4, `time`, `fresnelPower`, `rippleCount/Speed/Width`, `noiseScale/Strength` | Fresnel rim × animated ripple bands (`sin(angle·N + y·2 − t·speed)`) + hash distortion | +| **dissolve_burn** | unlit / **masked**, depthWrite on | `baseColor` f4, `edgeColor` f4, `threshold`, `edgeWidth`, `edgeIntensity`, `noiseScale`, `time` | 3D value-noise fbm; `discard` below threshold (precedent: translation_axis/depth_sampler); emissive edge glow band at the burn front | +| **water** | lit / transparent, depthWrite off (fallback: unlit + manual specular) | `deepColor`/`shallowColor` f4, `time`, `waveHeight`, 3× (`waveDir` f3, `waveFreq`, `waveSpeed`), `specularIntensity`, `foamThreshold`, `foamColor` f4 | **Vertex**: 3 summed Gerstner waves on subdivided grid (~64×64), finite-difference normals (re-evaluate displaced height at ±ε). **Fragment**: fresnel-driven opacity, depth-proxy color mix, foam on crests, sun glint | +| **smoke** | unlit / additive, depthWrite off, `featureLevel: 1` | `color` f4, `time`, `puffCount`, `riseSpeed`, `expandSpeed`, `swirlAmount`, `baseSize`, `noiseScale/Strength` | Single draw, N quads generated in **vertex shader** from `getVertexIndex()` (proceduralquad pattern); seed = `vid/6` → staggered start, per-puff rise/expand/rotate; fragment: soft radial falloff × 2D fbm scrolling with time, height fade | + +Scene composition per effect (camera, dark skybox vs IBL, lights) follows the existing examples in `examples_lib/lib/src/` (`materials_and_lighting.dart`, `lighting_setup.dart`). Additive materials (smoke, force_field, hit_flash) get near-black skyboxes; water/hologram get IBL (`default_env_ibl.ktx`). + +## Implementation order + +1. **hit_flash** — simplest; proves the whole pipeline end-to-end (`.mat` → matc → loadResource → createMaterial → params → PNG). +2. **hologram** → 3. **force_field** — view-dependent fresnel patterns. +4. **dissolve_burn** — masked blending + discard + fbm. +5. **water** — vertex-shader-heavy + `subdividedPlane` helper. +6. **smoke** — combines every technique; done last. + +Then: registry/exports/build.sh wiring (can be incremental per effect), `flutter analyze`, full `make materials` multi-backend build. + +## Iteration + verification loop (macOS) + +Filament build (matc/resgen source): `/Volumes/T7 1/projects/filament/out/cmake-release/tools` (Ninja layout — binaries at `tools/matc/matc`, `tools/resgen/resgen`; `materials/build.sh` now resolves both layouts). Quote paths in shell — the volume name contains a space. + +```bash +export FILAMENT_PATH="/Volumes/T7 1/projects/filament/out/cmake-release/tools" + +# compile one material (fast path — Metal only) +"$FILAMENT_PATH/matc/matc" -a metal -o examples/assets/.filamat examples/assets/.mat + +# render + capture +cd examples/dart/headless_runner && dart run bin/run_example.dart game_effects_ 768 768 +open output/game_effects_.png +``` + +- **Golden stills**: each setup sets a chosen "golden" time (e.g. `setParameterFloat('time', 2.0)`) before returning — parameters persist into the runner's capture; hooks are NOT needed for stills (capture bypasses them; `beforeRender` isn't awaited). +- **Animation sanity**: optional shared helper advances time and captures a small series (set param → `await capture()` → repeat) inside setup. +- **Live hit-flash timeline** (hook-driven swap): snapshot via `asset.getMaterialInstancesAsMap()`, swap to flash instance, animate `progress` in a `registerRequestFrameHook` (real `render()` calls fire hooks), restore via `setMaterialInstancesFromMap()`. +- `dart analyze` in `examples/dart/examples_lib` after each effect; final `FILAMENT_PATH="/Volumes/T7 1/projects/filament/out/cmake-release/tools" make materials` regenerates all-backend `.filamat` (this machine's matc lacks WebGPU, so the webgpu backend is skipped with a warning — rebuild with a `FILAMENT_SUPPORTS_WEBGPU=ON` matc to restore it). + +## Risks / fallbacks + +- **`blending: masked` + `discard`** on Metal — fallback: `blending: transparent` with alpha=0 instead of discard (or opaque+discard as in depth_sampler). +- **`shadingModel: lit` water double-lighting** — if lit fights the manual baseColor, switch to unlit + manual Blinn-Phong. +- **Smoke dummy geometry** — zeros positions must keep POSITION an active attribute (proceduralquad notes); `puffCount` must match `vertexCount/6`. +- **Gerstner normals** — verify no seams on grid edges; iterate at 32×32 before 64×64. +- **fbm quality (smoke)** — if "cotton balls", add octaves + domain-warped noise input. +- **Smoke vs background** — additive needs near-black skybox (in design). + +## Out of scope (follow-ups) + +- `game_effects` combined galleryScene for the web gallery; skills doc section; vertex-color shore foam / depth-based water color; overlay-renderable hit flash preserving PBR during flash. + +## Worktree + +All work is performed in a **local git worktree**, not the main checkout (which carries uncommitted `docs/agent-skills` work): + +```bash +git worktree add ~/claude-worktrees/thermion-game-effects -b game-effect-shaders develop +``` + +(branch name `game-effect-shaders`, based off `develop`.) All changes are additive (new files) plus three small edits (`registry.dart`, `examples_lib.dart`, `build.sh`). The headless runner's repo-relative asset paths (`../../assets`) resolve identically inside the worktree, and `game_effects_plan.md` / compiled `.filamat` are committed from there. diff --git a/materials/build.sh b/materials/build.sh index 234bcbff7..ea5f8d9da 100755 --- a/materials/build.sh +++ b/materials/build.sh @@ -35,13 +35,40 @@ if [ -z "${FILAMENT_PATH:-}" ]; then exit 1 fi +# FILAMENT_PATH must contain matc and resgen. Release/bin-style layouts +# have them as files; Ninja out//tools layouts have them as +# directories containing the binary (tools/matc/matc). Resolve both, and +# keep everything quoted - paths with spaces otherwise word-split. MATC="${FILAMENT_PATH}/matc" +if [ -d "${MATC}" ]; then + MATC="${MATC}/matc" +fi RESGEN="${FILAMENT_PATH}/resgen" +if [ -d "${RESGEN}" ]; then + RESGEN="${RESGEN}/resgen" +fi MATERIAL_DIR="thermion_dart/native/include/material" MATERIALS=(image unlit_fixed_size grid linear_depth silhouette edge_outline wireframe translation_axis bone_overlay capture_uv) # capture_uv is now in the main list; gizmo handled separately below GIZMO_NAME="gizmo" -EXAMPLE_MATERIALS=(customattributes solidcolor viewspace proceduralquad) +EXAMPLE_MATERIALS=(customattributes solidcolor viewspace proceduralquad hit_flash hologram hologram_projector force_field force_core dissolve_burn water smoke fire fire_ground lava shockwave_ground shockwave_dome shore_waves sand wetness crystal_ice snow_accumulation damage_decals portal_rift electricity invisibility_cloak energy_weapon) + +# Probe WebGPU support once: a matc built without FILAMENT_SUPPORTS_WEBGPU=ON +# cannot emit WGSL, so the _webgpu/_web_combined variants (and the webgpu +# backend in example .filamats) must be skipped rather than fail the build. +# Existing committed webgpu blobs are left untouched in that case. +WEBGPU_SUPPORTED=1 +if ! printf 'material { name : Probe, shadingModel : unlit, blending : opaque }\nfragment { void material(inout MaterialInputs m) { prepareMaterial(m); m.baseColor = vec4(1.0); } }\n' \ + > "${TMPDIR:-/tmp}/thermion_webgpu_probe.mat" \ + || ! "${MATC}" -a webgpu -o "${TMPDIR:-/tmp}/thermion_webgpu_probe.filamat" \ + "${TMPDIR:-/tmp}/thermion_webgpu_probe.mat" > /dev/null 2>&1; then + WEBGPU_SUPPORTED=0 + echo "WARNING: matc lacks WebGPU support (build Filament with" + echo "FILAMENT_SUPPORTS_WEBGPU=ON to enable it). Skipping _webgpu and" + echo "_web_combined variants; committed webgpu blobs are left as-is." +fi +rm -f "${TMPDIR:-/tmp}/thermion_webgpu_probe.mat" \ + "${TMPDIR:-/tmp}/thermion_webgpu_probe.filamat" # ------------------------------------------------------------------- # build_variant @@ -59,24 +86,27 @@ build_variant() { local upper upper=$(echo "${material}" | tr '[:lower:]' '[:upper:]') - local guard_name="${upper}_${suffix^^}_H_" + local upper_suffix + upper_suffix=$(echo "${suffix}" | tr '[:lower:]' '[:upper:]') + local guard_name="${upper}_${upper_suffix}_H_" echo " ${suffix}: matc ${arch_flags[*]}" - ${MATC} "${arch_flags[@]}" \ + "${MATC}" "${arch_flags[@]}" \ -o "materials/${material}.filamat" "materials/${material}.mat" || return 1 - ${RESGEN} -c -p "${material}" -x "${MATERIAL_DIR}/" "materials/${material}.filamat" || return 1 + "${RESGEN}" -c -p "${material}" -x "${MATERIAL_DIR}/" "materials/${material}.filamat" || return 1 # Rename to suffixed files mv "${MATERIAL_DIR}/${material}.c" "${MATERIAL_DIR}/${material}_${suffix}.c" mv "${MATERIAL_DIR}/${material}.h" "${MATERIAL_DIR}/${material}_${suffix}.h" # Fix #include in .c to point to suffixed .h - sed -i "s/#include \"${material}\\.h\"/#include \"${material}_${suffix}.h\"/" \ + # (perl rather than sed: BSD sed has no GNU-style in-place editing) + perl -i -pe "s/#include \"${material}\\.h\"/#include \"${material}_${suffix}.h\"/" \ "${MATERIAL_DIR}/${material}_${suffix}.c" # Fix header guard - sed -i "s/${upper}_H_/${guard_name}/" "${MATERIAL_DIR}/${material}_${suffix}.h" + perl -i -pe "s/${upper}_H_/${guard_name}/" "${MATERIAL_DIR}/${material}_${suffix}.h" # Prepend #include at top of .c echo "#include \"${material}_${suffix}.h\"" | cat - "${MATERIAL_DIR}/${material}_${suffix}.c" > \ @@ -149,9 +179,11 @@ for material in "${MATERIALS[@]}"; do build_variant "$material" desktop -a vulkan -a opengl build_variant "$material" opengl -a opengl build_variant "$material" vulkan -a vulkan - build_variant "$material" webgpu -a webgpu + if [ "${WEBGPU_SUPPORTED}" -eq 1 ]; then + build_variant "$material" webgpu -a webgpu + build_variant "$material" web_combined -a opengl -a webgpu + fi build_variant "$material" web_webgl -a opengl - build_variant "$material" web_combined -a opengl -a webgpu create_forwarding_header "$material" "$upper" @@ -167,7 +199,11 @@ done echo "=== gizmo rename special case ===" # Build gizmo as a regular material first -for suffix in apple android desktop opengl vulkan webgpu web_webgl web_combined; do +GIZMO_SUFFIXES="apple android desktop opengl vulkan webgpu web_webgl web_combined" +if [ "${WEBGPU_SUPPORTED}" -eq 0 ]; then + GIZMO_SUFFIXES="apple android desktop opengl vulkan web_webgl" +fi +for suffix in ${GIZMO_SUFFIXES}; do case "$suffix" in apple) matc_flags="-a metal" ;; android) matc_flags="-a vulkan -a opengl" ;; @@ -181,21 +217,22 @@ for suffix in apple android desktop opengl vulkan webgpu web_webgl web_combined; echo " ${suffix}: matc ${matc_flags}" - ${MATC} ${matc_flags} \ + "${MATC}" ${matc_flags} \ -o "materials/gizmo.filamat" "materials/gizmo.mat" || exit 1 - ${RESGEN} -c -p "gizmo" -x "${MATERIAL_DIR}/" "materials/gizmo.filamat" || exit 1 + "${RESGEN}" -c -p "gizmo" -x "${MATERIAL_DIR}/" "materials/gizmo.filamat" || exit 1 # Rename .c/.h to gizmo_material_ mv "${MATERIAL_DIR}/gizmo.c" "${MATERIAL_DIR}/gizmo_material_${suffix}.c" mv "${MATERIAL_DIR}/gizmo.h" "${MATERIAL_DIR}/gizmo_material_${suffix}.h" - # Fix #include - sed -i "s/#include \"gizmo\\.h\"/#include \"gizmo_material_${suffix}.h\"/" \ + # Fix #include (perl rather than sed: BSD sed has no GNU-style in-place + # editing) + perl -i -pe "s/#include \"gizmo\\.h\"/#include \"gizmo_material_${suffix}.h\"/" \ "${MATERIAL_DIR}/gizmo_material_${suffix}.c" # Fix header guard upper_suffix=$(echo "${suffix}" | tr '[:lower:]' '[:upper:]') - sed -i "s/GIZMO_H_/GIZMO_MATERIAL_${upper_suffix}_H_/" \ + perl -i -pe "s/GIZMO_H_/GIZMO_MATERIAL_${upper_suffix}_H_/" \ "${MATERIAL_DIR}/gizmo_material_${suffix}.h" # Prepend #include @@ -226,9 +263,13 @@ echo "" # ------------------------------------------------------------------- # Compile example asset materials (standalone .filamat, not embedded) # ------------------------------------------------------------------- +EXAMPLE_BACKENDS="-a opengl -a metal -a vulkan -a webgpu" +if [ "${WEBGPU_SUPPORTED}" -eq 0 ]; then + EXAMPLE_BACKENDS="-a opengl -a metal -a vulkan" +fi for material in "${EXAMPLE_MATERIALS[@]}"; do - echo "=== examples/assets/$material (all platforms) ===" - ${MATC} -a opengl -a metal -a vulkan -a webgpu \ + echo "=== examples/assets/$material (${EXAMPLE_BACKENDS}) ===" + "${MATC}" ${EXAMPLE_BACKENDS} \ -o "examples/assets/${material}.filamat" "examples/assets/${material}.mat" || exit 1 done diff --git a/thermion_dart/native/include/material/bone_overlay.bin b/thermion_dart/native/include/material/bone_overlay.bin index 4bc30ba7a..995d9db6e 100644 Binary files a/thermion_dart/native/include/material/bone_overlay.bin and b/thermion_dart/native/include/material/bone_overlay.bin differ diff --git a/thermion_dart/native/include/material/capture_uv.bin b/thermion_dart/native/include/material/capture_uv.bin index 4004a0c04..e4aed2004 100644 Binary files a/thermion_dart/native/include/material/capture_uv.bin and b/thermion_dart/native/include/material/capture_uv.bin differ diff --git a/thermion_dart/native/include/material/edge_outline.bin b/thermion_dart/native/include/material/edge_outline.bin index 532844415..9bb8acc70 100644 Binary files a/thermion_dart/native/include/material/edge_outline.bin and b/thermion_dart/native/include/material/edge_outline.bin differ diff --git a/thermion_dart/native/include/material/grid.bin b/thermion_dart/native/include/material/grid.bin index 858f00b8b..f733cc114 100644 Binary files a/thermion_dart/native/include/material/grid.bin and b/thermion_dart/native/include/material/grid.bin differ diff --git a/thermion_dart/native/include/material/image.bin b/thermion_dart/native/include/material/image.bin index 920fdb1d2..ee9cbc269 100644 Binary files a/thermion_dart/native/include/material/image.bin and b/thermion_dart/native/include/material/image.bin differ diff --git a/thermion_dart/native/include/material/linear_depth.bin b/thermion_dart/native/include/material/linear_depth.bin index 6120ea0d9..fbb8f6dab 100644 Binary files a/thermion_dart/native/include/material/linear_depth.bin and b/thermion_dart/native/include/material/linear_depth.bin differ diff --git a/thermion_dart/native/include/material/silhouette.bin b/thermion_dart/native/include/material/silhouette.bin index 4b5e06e43..4d137a255 100644 Binary files a/thermion_dart/native/include/material/silhouette.bin and b/thermion_dart/native/include/material/silhouette.bin differ diff --git a/thermion_dart/native/include/material/translation_axis.bin b/thermion_dart/native/include/material/translation_axis.bin index 02ba4f3c2..57629e7ed 100644 Binary files a/thermion_dart/native/include/material/translation_axis.bin and b/thermion_dart/native/include/material/translation_axis.bin differ diff --git a/thermion_dart/native/include/material/unlit_fixed_size.bin b/thermion_dart/native/include/material/unlit_fixed_size.bin index 50b761ec7..28d117627 100644 Binary files a/thermion_dart/native/include/material/unlit_fixed_size.bin and b/thermion_dart/native/include/material/unlit_fixed_size.bin differ diff --git a/thermion_dart/native/include/material/wireframe.bin b/thermion_dart/native/include/material/wireframe.bin index 0d846769b..6616f63be 100644 Binary files a/thermion_dart/native/include/material/wireframe.bin and b/thermion_dart/native/include/material/wireframe.bin differ