// Ocean — Vulkan PT demo of FFT-displaced water using DisplacedMesh. // // A three-cascade Phillips spectrum + GPU IFFT chain feeds vertex positions // directly into the BLAS each frame; the path tracer's existing transmission // BSDF handles refraction, Beer-Lambert absorption, and reflections. A simple // sandy floor sits below the surface so caustics from the photon-mapping // pass become visible as the sun moves. // // Built out well past the original single-cascade prototype: foam + Kelvin // wake, an adaptive vertex warp that packs density around the vessel, a // procedural archipelago ring, a lighthouse with a day/night toggle and a // procedural night sky, a maneuvering-model boat (manual + waypoint // autopilot) with buoys, plus path-traced LIDAR and a radar HUD. #include "threepp/audio/Audio.hpp" #include "threepp/extras/curves/CatmullRomCurve3.hpp" #include "threepp/extras/imgui/ImguiContext.hpp" #include "threepp/geometries/PlaneGeometry.hpp" #include "threepp/helpers/LidarWaveform.hpp" #include "threepp/helpers/PathTracedLidarSensor.hpp" #include "threepp/input/KeyListener.hpp" #include "threepp/lights/AmbientLight.hpp" #include "threepp/lights/DirectionalLight.hpp" #include "threepp/geometries/LatheGeometry.hpp" #include "threepp/geometries/TorusGeometry.hpp" #include "threepp/loaders/GLTFLoader.hpp" #include "threepp/loaders/RGBELoader.hpp" #include "threepp/utils/BufferGeometryUtils.hpp" #include "threepp/utils/Parallel.hpp" #include "threepp/materials/MeshPhysicalMaterial.hpp" #include "threepp/materials/MeshStandardMaterial.hpp" #include "threepp/math/Box3.hpp" #include "threepp/math/Matrix3.hpp" #include "threepp/math/Matrix4.hpp" #include "threepp/objects/DisplacedMesh.hpp" #include "threepp/renderers/VulkanRenderer.hpp" #include "threepp/textures/DataTexture.hpp" #include "threepp/threepp.hpp" #include "capture_util.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace threepp; namespace { // Boat input state — captured by KeyListener, polled each frame. struct BoatInput : KeyListener { bool W = false, A = false, S = false, D = false; bool shotRequest = false;// F12: dump the next frame to aaa_caps/ (artifact reports) void onKeyPressed(KeyEvent e) override { if (e.key == Key::F12) shotRequest = true; update(e.key, true); } void onKeyReleased(KeyEvent e) override { update(e.key, false); } void update(Key k, bool down) { if (k == Key::W || k == Key::UP) W = down; if (k == Key::S || k == Key::DOWN) S = down; if (k == Key::A || k == Key::LEFT) A = down; if (k == Key::D || k == Key::RIGHT) D = down; } }; // Persistent boat state — a reduced 3-DOF maneuvering model (surge, sway, // yaw) in the horizontal plane, with wave-driven heave/pitch/roll layered // on top each frame. // • Surge: engine thrust vs quadratic hull drag — natural top speed // (~14 kn for a vessel of Gunnerus's size) and a long coast-down. // • Yaw: first-order Nomoto response. The rudder commands a steady-state // turn rate proportional to flow over the blade; hull yaw inertia makes // the rate build/decay over T ≈ L/u seconds instead of snapping. // • Sway: centripetal coupling gives the outward drift a displacement // hull carries through a turn (velocity lags heading by the drift angle). // • Actuators: the rudder slews at a finite rate and the throttle spools // like an engine telegraph. Manual keys and the waypoint autopilot // both drive these same actuators, so motion is identical either way. struct BoatState { Vector3 position{0.f, 0.f, 0.f}; // world; y unused (heave is separate) float yaw = 0.f; // heading, radians around +Y float yawRate = 0.f; // r, rad/s (state — yaw inertia) float forwardSpeed = 0.f; // surge u, m/s along +heading float swaySpeed = 0.f; // sway v, m/s lateral (drift in turns) float rudder = 0.f; // actual rudder deflection, radians float throttle = 0.f; // engine telegraph state ∈ [−1, 1] bool aground = false; // keel touching (island shores / sills) float smoothPitch = 0.f; // radians, low-passed from wave tilt float smoothRoll = 0.f; // radians float y = 0.f; // metres, spring-damped toward wave height float vY = 0.f; // m/s, heave velocity (state for the spring-damper) }; }// namespace namespace { constexpr float kTileSize = 1000.0f; // metres — full mesh extent and cascade-0 tile constexpr uint32_t kFftSize = 1024; // FFT resolution per cascade — drives wave detail, NOT mesh density. constexpr float kPlaneEdge = kTileSize; // mesh extends one full FFT tile in X and Z // Mesh density is decoupled from FFT size: the water_displace.comp samples // the height texture via normalised UVs (u = i / (gridDim-1)) so the mesh // can be any tessellation while the wave field stays at kFftSize². Halving // the subdivision from kFftSize-1 to kFftSize/2-1 drops the vertex count // from ~1 M to ~262 K — a 4× win on BLAS rebuild/refit and the per-vertex // displace dispatch, while wave geometry stays crisp (vertex spacing ~2 m // still resolves λ ≥ 4 m, and Phillips 1/k⁴ puts most energy above that). constexpr int kSubdiv = static_cast(kFftSize) / 2 - 1; auto makeOceanMaterial() { auto mat = MeshPhysicalMaterial::create(); // Pure water has no diffuse pigment — the blue comes from Beer-Lambert // absorption through the medium, not albedo. mat->color = Color::white; // Small roughness simulates the sub-pixel chop the FFT can't resolve. // 0.04 broadens the specular lobe just enough that each highlight // covers multiple pixels — converges fast under TAA, avoids the // salt-and-pepper sparkle that 0.01 + a tight-mip normal map gives // on distant water. mat->roughness = 0.04f; mat->metalness = 0.0f; mat->setIor(1.33f); mat->transmission = 1.0f; // doubleSided + thickness opts this surface into the path tracer's // thin-shell transmission path: every transmission crossing applies // Beer-Lambert for `thickness` metres of in-medium depth. The down- // crossing (camera → water) tints the refracted ray; the up-crossing // (sand → camera, after bounce) tints again. 2 m × 2 ≈ 4 m of // effective tint — a tropical-ocean blue that still shows refracted // sand under the brightest crests. Without doubleSided the BSDF // would need to use the actual ray distance through the medium // (~12 m here), which over-saturates to near-black. mat->side = Side::Double; mat->thickness = 2.0f; // Opt this surface into the path tracer's thin-shell BSDF: a single // FFT-displaced plane has no closed interior, so both faces should // refract as entries and Beer-Lambert applies per-crossing using // `thickness` as the in-medium proxy. Without this flag, the back- // face hit (ray bouncing off sand) would refract using eta=ior with // gl_HitTEXT = full water column → opaque deep blue, no see-through. mat->thinWalled = true; mat->attenuationColor = Color(0.10f, 0.45f, 0.55f); mat->attenuationDistance = 3.0f; mat->clearcoat = 0.1; // Sub-mesh-resolution wave detail comes from the FFT fine cascade // sampled directly in closest_hit (binding 32 → cascade-2 height, // gated on `thinWalled`). That animates with the wave field for free // and replaces the procedural normal map this example used to ship. return mat; } auto makeSandMaterial() { return MeshStandardMaterial::create(MeshStandardMaterial::Params{}.color(Color(0.02,0.02,0.02)).roughness(1.0f)); } }// namespace // ── Procedural enclosing archipelago ──────────────────────────────────────── // A ring of rocky islands around the play area (r ≈ 385–495 m) so the scene // reads as a sheltered Norwegian skerry bay instead of bare open horizon. // Deterministic value-noise FBM drives everything: an angular "mass plan" // (periodic by construction — noise sampled on a circle) picks where islands // rise and where passes stay open sea; a radial bump profile dives both // shores below the sand floor so the rims bury cleanly; ridged FBM adds the // rocky relief. Surface detail comes from three maps baked in the mesh's own // polar parameterisation (the Vulkan PT has no vertex-colour path for // meshes): an albedo map layering two granite tones, strata banding, scree, // grass / heather / lichen niches and a wet waterline; a tangent-space // normal map carrying creased-slab relief far below the vertex grid; and a // roughness map that turns the wet band glossy — all derived from the same // height field. namespace island { constexpr float kInnerR = 385.f;// boat waypoints reach ~320 m — keep clear water constexpr float kOuterR = 495.f;// stays inside the 1 km ocean/sand tile constexpr float kPeakH = 55.f; // tallest summits (m) constexpr float kSkirt = 7.f; // rim depth — below the sand floor (-5 m) float smoothstepf(float e0, float e1, float x) { const float t = std::clamp((x - e0) / (e1 - e0), 0.f, 1.f); return t * t * (3.f - 2.f * t); } float hashf(int xi, int zi) { uint32_t h = static_cast(xi) * 374761393u + static_cast(zi) * 668265263u; h = (h ^ (h >> 13)) * 1274126177u; h ^= h >> 16; return static_cast(h) * (1.f / 4294967296.f); } // Value noise with quintic fade, range [0,1]. float vnoise(float x, float z) { const float fx = std::floor(x), fz = std::floor(z); const int xi = static_cast(fx), zi = static_cast(fz); float tx = x - fx, tz = z - fz; tx = tx * tx * tx * (tx * (tx * 6.f - 15.f) + 10.f); tz = tz * tz * tz * (tz * (tz * 6.f - 15.f) + 10.f); const float a = hashf(xi, zi), b = hashf(xi + 1, zi); const float c = hashf(xi, zi + 1), d = hashf(xi + 1, zi + 1); return a + (b - a) * tx + (c - a) * tz + (a - b - c + d) * tx * tz; } float fbm(float x, float z, int octaves) { float sum = 0.f, amp = 0.5f, norm = 0.f; for (int o = 0; o < octaves; ++o) { sum += amp * vnoise(x, z); norm += amp; amp *= 0.5f; // irrational-ish lacunarity + offset decorrelates the octave lattices const float nx = x * 1.93f + 19.7f, nz = z * 2.11f + 7.3f; x = nx; z = nz; } return sum / norm; } float heightAt(float x, float z) { const float r = std::sqrt(x * x + z * z); // ring coordinate, warped so the coastlines wander instead of circling float w = (r - kInnerR) / (kOuterR - kInnerR); w += 0.20f * (2.f * fbm(x * 0.011f, z * 0.011f, 2) - 1.f); if (w <= 0.f || w >= 1.f) return -kSkirt; const float prof = std::pow(std::sin(math::PI * w), 1.5f); // mass plan: low-frequency noise on a circle, thresholded — sections // below the band stay a submerged sill (the passes between islands) const float a = std::atan2(z, x); const float m0 = 0.65f * vnoise(7.3f + std::cos(a) * 2.9f, 3.1f + std::sin(a) * 2.9f) + 0.35f * vnoise(13.7f + std::cos(a) * 5.3f, 23.9f + std::sin(a) * 5.3f); const float m = smoothstepf(0.27f, 0.60f, m0); // ridged FBM (fold-over of signed noise) = sharp rocky crests const float ridge = 1.f - std::abs(2.f * fbm(x * 0.035f, z * 0.035f, 4) - 1.f); // meso relief: layered creased-slab ridges carried as REAL geometry by // the dense vertex grid — λ ≈ 11 m slabs, ≈ 4 m ledges, and sub-metre // bumps (≈ 1.8 m and ≈ 0.9 m). Amplitudes are absolute (a ledge is a // ledge, independent of the mountain's height) and stay gentler than // their own wavelength. Scaled by the mass plan so the submerged passes // stay smooth sills; the normal map now carries only the truly // sub-vertex scales (≤ ~0.6 m). const float slab = 1.f - std::abs(2.f * fbm(x * 0.09f, z * 0.09f, 3) - 1.f); const float slabFn = 1.f - std::abs(2.f * fbm(x * 0.24f, z * 0.24f, 3) - 1.f); const float bump = fbm(x * 0.55f + 41.f, z * 0.55f + 17.f, 2); const float grainHi = fbm(x * 1.15f + 7.f, z * 1.15f + 91.f, 2); const float meso = 3.5f * (slab * slab - 0.45f) + 1.8f * (slabFn * slabFn - 0.45f) + 0.7f * (bump - 0.5f) + 0.35f * (grainHi - 0.5f); const float crest = (kPeakH * (0.45f + 0.55f * ridge * ridge) + meso) * m; // prof=1 mid-ring: passes top out 2 m underwater, islands rise to crest return -kSkirt + (crest + kSkirt - 2.f) * prof; } // Analytic finite-difference normal — seam-consistent (the height field is // continuous in angle) and adds sub-vertex shading detail for free. The // 0.6 m radius matches the denser ~0.4 m vertex grid so the sub-metre // relief shades correctly instead of being averaged away. Vector3 normalAt(float x, float z, float eps = 0.6f) { const float dhdx = (heightAt(x + eps, z) - heightAt(x - eps, z)) / (2.f * eps); const float dhdz = (heightAt(x, z + eps) - heightAt(x, z - eps)) / (2.f * eps); Vector3 n(-dhdx, 1.f, -dhdz); return n.normalize(); } // High-frequency rock relief for the baked normal map — layered creased // slabs (λ ≈ 3 m and ≈ 1.3 m), value-noise grain (λ ≈ 1 m) and fine pepper // (λ ≈ 0.5 m and ≈ 0.25 m): only the scales below what the vertex grid // carries (the λ ≥ 3.5 m slabs live in heightAt). The finest octave only // pays off at the doubled normal-map resolution + the tightened gradient // step (`de`) bakeMaps now uses. float detailHeight(float x, float z) { const float r2 = 1.f - std::abs(2.f * fbm(x * 0.31f + 53.f, z * 0.31f + 17.f, 3) - 1.f); const float r3 = 1.f - std::abs(2.f * fbm(x * 0.72f + 101.f, z * 0.72f + 61.f, 2) - 1.f); const float g = fbm(x * 0.9f + 9.f, z * 0.9f + 27.f, 2); const float gf = fbm(x * 1.9f + 33.f, z * 1.9f + 71.f, 2); const float gff = fbm(x * 4.0f + 5.f, z * 4.0f + 53.f, 2); return 0.50f * r2 * r2 + 0.22f * r3 * r3 + 0.10f * g + 0.05f * gf + 0.03f * gff; } struct BakedMaps { std::shared_ptr albedo; std::shared_ptr normal; std::shared_ptr rough; }; // Albedo + tangent-space normal + roughness in the mesh's polar // parameterisation (u = angle, v = radius): one texel ≈ 0.13 m of // coastline, 0.11 m radially. Each texel re-evaluates the height field — // centre + 4 neighbours give the slope normal AND the Laplacian // (crest/hollow) from the same five probes — so every mask tracks the // actual geometry. Rows bake in parallel; the pass is a one-off at startup. BakedMaps bakeMaps() { const int W = 20480, H = 1024; std::vector albPx(static_cast(W) * H * 4); std::vector nrmPx(static_cast(W) * H * 4); std::vector rghPx(static_cast(W) * H * 4); auto toByte = [](float v) { return static_cast(std::lround(std::clamp(v, 0.f, 1.f) * 255.f)); }; std::vector rows(H); std::iota(rows.begin(), rows.end(), 0); threepp::parallelForEach(rows.begin(), rows.end(), [&](int y) { const float r = kInnerR + (kOuterR - kInnerR) * ((y + 0.5f) / H); for (int x = 0; x < W; ++x) { const float a = 2.f * math::PI * ((x + 0.5f) / W); const float ca = std::cos(a), sa = std::sin(a); const float wx = ca * r, wz = sa * r; const float eps = 2.5f; const float h = heightAt(wx, wz); const float hxp = heightAt(wx + eps, wz), hxm = heightAt(wx - eps, wz); const float hzp = heightAt(wx, wz + eps), hzm = heightAt(wx, wz - eps); const float dhdx = (hxp - hxm) / (2.f * eps); const float dhdz = (hzp - hzm) / (2.f * eps); const float ny = 1.f / std::sqrt(1.f + dhdx * dhdx + dhdz * dhdz); const float lap = (hxp + hxm + hzp + hzm - 4.f * h) / (eps * eps); // convexity: exposed crests bleach, concave seams collect dirt const float crest = std::clamp(-lap * 0.8f, -1.f, 1.f); const float tone = fbm(wx * 0.0045f, wz * 0.0045f, 2); // per-face rock tone, λ ≈ 220 m const float mottle = fbm(wx * 0.05f, wz * 0.05f, 3); // λ ≈ 20 m const float grain = fbm(wx * 0.7f + 5.f, wz * 0.7f + 13.f, 2);// λ ≈ 1.4 m const float micro = fbm(wx * 1.6f + 211.f, wz * 1.6f + 97.f, 2);// λ ≈ 0.6 m const float vegL = fbm(wx * 0.013f + 31.f, wz * 0.013f, 3); // veg patches, λ ≈ 75 m const float vegS = fbm(wx * 0.11f + 7.f, wz * 0.11f + 3.f, 2);// ragged veg edges, λ ≈ 9 m const float warp = fbm(wx * 0.03f + 71.f, wz * 0.03f + 11.f, 2); // two-tone base: pale weathered granite vs darker gneiss, // blended at island scale so each face reads as its own mass const float t = smoothstepf(0.35f, 0.65f, tone); float cr = 0.26f + 0.20f * t; float cg = 0.245f + 0.195f * t; float cb = 0.235f + 0.175f * t; const float speck = (mottle - 0.5f) * 0.14f + (grain - 0.5f) * 0.09f + (micro - 0.5f) * 0.05f; cr += speck; cg += speck; cb += speck * 0.9f; // wandering sub-horizontal strata bands on steep faces const float strata = 1.f + 0.09f * std::sin(h * 0.75f + 6.f * warp) * smoothstepf(0.85f, 0.55f, ny); // crest/hollow shading from the Laplacian const float cav = 1.f + 0.14f * crest; cr *= strata * cav; cg *= strata * cav; cb *= strata * cav; // scree aprons: gentle low benches at the cliff feet collect debris const float scree = smoothstepf(0.60f, 0.78f, ny) * smoothstepf(1.2f, 2.6f, h) * (1.f - smoothstepf(5.f, 13.f, h)) * smoothstepf(0.35f, 0.65f, vegS) * 0.55f; cr += (0.41f - cr) * scree; cg += (0.375f - cg) * scree; cb += (0.315f - cb) * scree; // heather/shrub on mid slopes, its own patch noise const float hePatch = fbm(wx * 0.019f + 57.f, wz * 0.019f + 91.f, 2); const float heather = smoothstepf(0.45f, 0.62f, ny) * smoothstepf(1.5f, 3.5f, h) * (1.f - smoothstepf(22.f, 34.f, h)) * smoothstepf(0.45f, 0.62f, hePatch); cr += (0.205f + 0.05f * hePatch - cr) * heather; cg += (0.17f + 0.05f * hePatch - cg) * heather; cb += (0.105f - cb) * heather; // grass/moss on flat low benches; hollows accumulate soil, so // the Laplacian feeds the patch threshold const float gPatch = 0.55f * vegL + 0.30f * vegS + 0.15f * std::clamp(lap * 1.5f, 0.f, 1.f); const float grass = smoothstepf(0.60f, 0.80f, ny) * smoothstepf(0.8f, 2.6f, h) * (1.f - smoothstepf(24.f, 38.f, h)) * smoothstepf(0.42f, 0.58f, gPatch); cr += (0.13f + 0.07f * vegS - cr) * grass; cg += (0.27f + 0.08f * vegL - cg) * grass; cb += (0.085f - cb) * grass; // pale lichen crusts on exposed high rock const float lich = smoothstepf(8.f, 20.f, h) * smoothstepf(0.55f, 0.75f, fbm(wx * 0.15f + 13.f, wz * 0.15f + 29.f, 2)) * std::clamp(0.5f + 0.5f * crest, 0.f, 1.f) * 0.30f; cr += (0.50f - cr) * lich; cg += (0.51f - cg) * lich; cb += (0.46f - cb) * lich; // summits bleach toward bare washed rock const float alt = 1.f + 0.08f * smoothstepf(28.f, 52.f, h); cr *= alt; cg *= alt; cb *= alt; // Conifer forest — the signature cover of these slopes. Climbs // steep ground (unlike grass) but sheds off sheer cliff faces; // a coarse stand field + a ragged canopy-edge noise carve // clearings and break the treeline so it never reads as a // contour line. Deep blue-green, varied stand to stand. const float forestStand = fbm(wx * 0.020f + 13.f, wz * 0.020f + 47.f, 3);// λ ≈ 50 m const float forestEdge = fbm(wx * 0.13f + 61.f, wz * 0.13f + 29.f, 2); // λ ≈ 8 m const float forest = smoothstepf(0.40f, 0.58f, ny) * smoothstepf(1.5f, 4.0f, h) * (1.f - smoothstepf(26.f, 36.f, h)) * smoothstepf(0.34f, 0.52f, 0.6f * forestStand + 0.4f * forestEdge); cr += (0.045f + 0.030f * forestStand - cr) * forest; cg += (0.135f + 0.060f * forestStand - cg) * forest; cb += (0.050f + 0.020f * forestStand - cb) * forest; // Snow — settles on low-angle ground above the snowline and // pools in the concave couloirs (lap > 0) well below it, while // shedding off anything approaching vertical. Faint cool tint // on the ambient-shadowed fields; a patch field keeps the // upper edge ragged instead of a hard ring. At this 55 m skerry // scale snow reads as summit caps + gully streaks, not full // alpine cover — matching a low Norwegian coastal massif. const float snowPatch = fbm(wx * 0.05f + 91.f, wz * 0.05f + 5.f, 2); const float snowSlope = smoothstepf(0.42f, 0.72f, ny); const float snowfield = smoothstepf(26.f, 42.f, h); const float couloir = std::clamp(lap * 1.4f, 0.f, 1.f) * smoothstepf(16.f, 28.f, h); float snow = snowSlope * std::clamp(std::max(snowfield, couloir), 0.f, 1.f); snow *= 0.6f + 0.4f * smoothstepf(0.35f, 0.65f, snowPatch); cr += (0.92f - cr) * snow; cg += (0.94f - cg) * snow; cb += (0.98f - cb) * snow; // algae film straddling the waterline, then the dark wet band const float algae = (1.f - smoothstepf(0.6f, 1.4f, std::abs(h - 0.3f))) * 0.5f; cr += (0.10f - cr) * algae; cg += (0.15f - cg) * algae; cb += (0.10f - cb) * algae; const float wet = (1.f - smoothstepf(0.4f, 2.2f, h)) * 0.85f; cr += (0.095f - cr) * wet; cg += (0.095f - cg) * wet; cb += (0.09f - cb) * wet; // Snowmelt waterfalls — sparse thin ribbons down the steep // faces. A slow azimuthal selector picks a few fall-lines and // a high-frequency stripe makes each one narrow; both depend // only on the angle, so a ribbon runs unbroken down the face // as the radius row changes. Gated to steep rock between the // splash zone and the snow source above. Bright and slightly // blue; the glossy wet sheen is added in the roughness block. const float fallRegion = smoothstepf(0.60f, 0.82f, fbm(a * 2.5f + 11.f, 4.0f, 2)); const float fallStripe = std::pow(std::max(0.f, std::sin(a * 48.f + 6.f * fbm(a * 9.f, 2.f, 2))), 60.f); const float fall = fallRegion * fallStripe * smoothstepf(0.30f, 0.55f, 1.f - ny) * smoothstepf(4.f, 9.f, h) * (1.f - smoothstepf(34.f, 44.f, h)); cr += (0.82f - cr) * fall; cg += (0.86f - cg) * fall; cb += (0.92f - cb) * fall; // detail normal: world-plane gradient of the relief field, // projected onto the polar tangent frame (T = +u = angular, // B = +v = radial — matches the shader's derivative TBN). // Damped under vegetation: soil and moss smooth micro-relief. // de ≈ the doubled radial texel spacing (~0.11 m) so the // sub-0.5 m octaves of detailHeight resolve instead of being // averaged out. const float de = 0.12f; const float canopy = std::max({grass, heather, forest}); const float damp = 0.8f * (1.f - 0.6f * canopy) * (1.f - 0.85f * snow); const float gx = (detailHeight(wx + de, wz) - detailHeight(wx - de, wz)) / (2.f * de) * damp; const float gz = (detailHeight(wx, wz + de) - detailHeight(wx, wz - de)) / (2.f * de) * damp; const float st = -gx * sa + gz * ca;// slope along +u (angular) const float sb = gx * ca + gz * sa; // slope along +v (radial) const float inv = 1.f / std::sqrt(st * st + sb * sb + 1.f); // roughness (.g multiplies material roughness): matte dry // granite, matte vegetation, matte conifer canopy, water- // slicked rock turns glossy, bright soft snow, glossy falls float rough = 0.86f + 0.10f * (mottle - 0.5f) - 0.06f * crest; rough += (0.95f - rough) * std::max(grass, heather); rough += (0.93f - rough) * forest; rough += (0.45f - rough) * wet; rough += (0.62f - rough) * snow; rough += (0.38f - rough) * fall; const size_t i = (static_cast(y) * W + x) * 4; albPx[i + 0] = toByte(cr); albPx[i + 1] = toByte(cg); albPx[i + 2] = toByte(cb); albPx[i + 3] = 255; nrmPx[i + 0] = toByte(-st * inv * 0.5f + 0.5f); nrmPx[i + 1] = toByte(-sb * inv * 0.5f + 0.5f); nrmPx[i + 2] = toByte(inv * 0.5f + 0.5f); nrmPx[i + 3] = 255; rghPx[i + 0] = 255; rghPx[i + 1] = toByte(rough); rghPx[i + 2] = 0; rghPx[i + 3] = 255; } }); auto makeTex = [&](std::vector&& px, bool srgb) { auto tex = DataTexture::create(ImageData{std::move(px)}, static_cast(W), static_cast(H)); if (srgb) tex->colorSpace = ColorSpace::sRGB;// normal/rough stay raw UNORM tex->magFilter = Filter::Linear; tex->minFilter = Filter::LinearMipmapLinear; tex->generateMipmaps = true; tex->needsUpdate(); return tex; }; return {makeTex(std::move(albPx), true), makeTex(std::move(nrmPx), false), makeTex(std::move(rghPx), false)}; } std::shared_ptr build() { // 8192 angular columns ≈ 0.34 m spacing at mid-ring, 256 radial rows // ≈ 0.43 m — fine enough to carry the new sub-metre relief in heightAt // as real geometry instead of normal-map fakery. The seam column is // duplicated (u = 0 and u = 1) so UVs never wrap. ~2.1 M verts / // ~4.2 M tris, static BLAS built once; rows fill in parallel // (≈ 10 M height-field probes). Cheap for ray tracing (one static // BLAS) and trivial for the deferred raster pass on a modern GPU. const int NA = 8192, NR = 256; std::vector pos(static_cast(NA + 1) * (NR + 1) * 3); std::vector nrm(static_cast(NA + 1) * (NR + 1) * 3); std::vector uv(static_cast(NA + 1) * (NR + 1) * 2); std::vector rows(NR + 1); std::iota(rows.begin(), rows.end(), 0); threepp::parallelForEach(rows.begin(), rows.end(), [&](int j) { const float r = kInnerR + (kOuterR - kInnerR) * (static_cast(j) / NR); for (int i = 0; i <= NA; ++i) { const float a = 2.f * math::PI * (static_cast(i) / NA); const float x = std::cos(a) * r, z = std::sin(a) * r; const Vector3 n = normalAt(x, z); const size_t v = static_cast(j) * (NA + 1) + i; pos[v * 3 + 0] = x; pos[v * 3 + 1] = heightAt(x, z); pos[v * 3 + 2] = z; nrm[v * 3 + 0] = n.x; nrm[v * 3 + 1] = n.y; nrm[v * 3 + 2] = n.z; uv[v * 2 + 0] = static_cast(i) / NA; uv[v * 2 + 1] = static_cast(j) / NR; } }); std::vector idx; idx.reserve(static_cast(NA) * NR * 6); for (int j = 0; j < NR; ++j) for (int i = 0; i < NA; ++i) { const unsigned a0 = j * (NA + 1) + i;// (i, j) const unsigned b0 = a0 + 1; // (i+1, j) const unsigned a1 = a0 + (NA + 1); // (i, j+1) const unsigned b1 = a1 + 1; // (i+1, j+1) idx.insert(idx.end(), {a0, b0, b1}); idx.insert(idx.end(), {a0, b1, a1}); } auto geo = BufferGeometry::create(); geo->setIndex(idx); geo->setAttribute("position", FloatBufferAttribute::create(pos, 3)); geo->setAttribute("normal", FloatBufferAttribute::create(nrm, 3)); geo->setAttribute("uv", FloatBufferAttribute::create(uv, 2)); geo->computeBoundingBox(); geo->computeBoundingSphere(); auto mat = MeshStandardMaterial::create(MeshStandardMaterial::Params{} .roughness(1.f)// baked map carries the variation .metalness(0.f)); const BakedMaps maps = bakeMaps(); mat->map = maps.albedo; mat->normalMap = maps.normal; mat->roughnessMap = maps.rough; auto mesh = Mesh::create(geo, mat); mesh->frustumCulled = false;// the ring surrounds the camera — always partly in view return mesh; } }// namespace island // ── Procedural looping audio (engine + ocean/wind ambience) ───────────────── // Same temp-WAV approach as the Shooter example: the Audio API loads files, // so the loops are synthesised once at startup and written to the temp dir. // Seamless looping: every deterministic component (engine harmonics, swell / // gust LFOs) is given an exact integer number of cycles over the loop length, // then the synth renders an extra tail whose start is crossfaded back onto // the head — periodic terms pass through the wrap unchanged while the noise // and one-pole filter states blend across it. namespace { struct OnePole { float y = 0.f; float operator()(float x, float a) { y += a * (x - y); return y; } }; float lpAlpha(float cutoffHz, int sr) { return 1.f - std::exp(-2.f * math::PI * cutoffHz / static_cast(sr)); } std::vector normalized(std::vector s, float peak) { float m = 0.f; for (float x : s) m = std::max(m, std::abs(x)); if (m > 1e-6f) for (float& x : s) x *= peak / m; return s; } // Fold the `extra`-sample overhang back onto the head (linear crossfade). // out[0] == s[n] so the n-1 → 0 junction is the continuation of the tail; // by i == extra the signal is back on the head verbatim. std::vector loopable(const std::vector& s, int n, int extra) { std::vector out(s.begin(), s.begin() + n); for (int i = 0; i < extra; ++i) { const float w = static_cast(i) / static_cast(extra); out[i] = s[n + i] * (1.f - w) + s[i] * w; } return out; } // 16-bit mono PCM WAV writer (verbatim from the Shooter example). void writeWav(const std::filesystem::path& path, const std::vector& samples, int sr = 44100) { std::ofstream f(path, std::ios::binary); auto u32 = [&](uint32_t v) { f.write(reinterpret_cast(&v), 4); }; auto u16 = [&](uint16_t v) { f.write(reinterpret_cast(&v), 2); }; const uint32_t dataBytes = static_cast(samples.size()) * 2u; f.write("RIFF", 4); u32(36 + dataBytes); f.write("WAVE", 4); f.write("fmt ", 4); u32(16); u16(1);// PCM u16(1);// mono u32(sr); u32(sr * 2); u16(2); u16(16); f.write("data", 4); u32(dataBytes); for (float x : samples) { const auto q = static_cast(std::lround(std::clamp(x, -1.f, 1.f) * 32767.f)); f.write(reinterpret_cast(&q), 2); } } // Marine diesel at mid RPM, 2 s loop. Firing rate f0 = 27 Hz (54 exact // cycles): harmonic stack for the tonal drone, a |sin|³ "chug" envelope // gating low-passed exhaust noise, and a faint band-passed mechanical // clatter. Played at rate 0.7 (idle) … 1.6 (full ahead) by the updater. std::vector synthEngineLoop(int sr = 44100) { const float dur = 2.0f; const int n = static_cast(sr * dur); const int extra = sr / 4; std::mt19937 r(7); auto rn = [&] { return std::uniform_real_distribution(-1.f, 1.f)(r); }; const float f0 = 27.f; OnePole lpExhaust, lpClatHi, lpClatLo; const float aExhaust = lpAlpha(170.f, sr); const float aClatHi = lpAlpha(1300.f, sr); const float aClatLo = lpAlpha(450.f, sr); std::vector s(n + extra); for (int i = 0; i < n + extra; ++i) { const float t = static_cast(i) / sr; const float chug = std::pow(0.55f + 0.45f * std::abs(std::sin(math::PI * f0 * t)), 3.f); float tone = 0.f; tone += std::sin(2.f * math::PI * f0 * t) * 0.55f; tone += std::sin(2.f * math::PI * 2.f * f0 * t) * 0.30f; tone += std::sin(2.f * math::PI * 3.f * f0 * t) * 0.16f; tone += std::sin(2.f * math::PI * 4.f * f0 * t) * 0.09f; const float w = rn(); const float exhaust = lpExhaust(w, aExhaust) * chug * 1.7f; const float clatter = (lpClatHi(w, aClatHi) - lpClatLo(w, aClatLo)) * (0.4f + 0.6f * chug) * 0.45f; s[i] = tone * (0.7f + 0.3f * chug) + exhaust + clatter; } return normalized(loopable(s, n, extra), 0.7f); } // Rolling sea, 8 s loop: deep low-passed noise swelling on three loop- // exact LFOs (k/8 Hz), plus a brighter band-passed "wash" that peaks on // its own sharper envelope — the crest-breaking hiss over the rumble. std::vector synthOceanLoop(int sr = 44100) { const float dur = 8.0f; const int n = static_cast(sr * dur); const int extra = sr; std::mt19937 r(11); auto rn = [&] { return std::uniform_real_distribution(-1.f, 1.f)(r); }; OnePole lpDeep, lpWashHi, lpWashLo; const float aDeep = lpAlpha(240.f, sr); const float aWashHi = lpAlpha(1500.f, sr); const float aWashLo = lpAlpha(500.f, sr); std::vector s(n + extra); for (int i = 0; i < n + extra; ++i) { const float t = static_cast(i) / sr; float swell = 0.6f * std::sin(2.f * math::PI * 0.125f * t) + 0.3f * std::sin(2.f * math::PI * 0.375f * t + 1.7f) + 0.1f * std::sin(2.f * math::PI * 0.625f * t + 4.1f); swell = 0.55f + 0.45f * swell; const float washEnv = std::pow(0.5f + 0.5f * std::sin(2.f * math::PI * 0.25f * t + 2.6f), 3.f); const float w = rn(); const float deep = lpDeep(w, aDeep) * swell * 1.0f; const float wash = (lpWashHi(w, aWashHi) - lpWashLo(w, aWashLo)) * washEnv * 0.55f; s[i] = deep + wash; } return normalized(loopable(s, n, extra), 0.6f); } // Wind, 8 s loop. NOT a flat noise band — that reads as TV static. The // "whoosh" character comes from (a) a NARROW low band whose cutoff SWEEPS // upward with the gust envelope (the rising pitch of a building gust), // (b) 12 dB/oct edges — cascaded one-poles; a single pole leaks so much // above cutoff that the leak IS the white-noise hiss — and (c) a hard // lull↔gust amplitude swing (gust², near-silent lulls) so it reads as // weather, not a constant carrier. A faint flutter band rides only the // gust peaks (gust⁴). Gust LFOs are loop-exact (k/8 Hz). std::vector synthWindLoop(int sr = 44100) { const float dur = 8.0f; const int n = static_cast(sr * dur); const int extra = sr; std::mt19937 r(13); auto rn = [&] { return std::uniform_real_distribution(-1.f, 1.f)(r); }; OnePole hi1, hi2, lo1, lo2, fl1, fl2; const float aFlHi = lpAlpha(1000.f, sr); const float aFlLo = lpAlpha(450.f, sr); std::vector s(n + extra); for (int i = 0; i < n + extra; ++i) { const float t = static_cast(i) / sr; float gust = 0.55f * std::sin(2.f * math::PI * 0.25f * t) + 0.30f * std::sin(2.f * math::PI * 0.5f * t + 1.3f) + 0.15f * std::sin(2.f * math::PI * 0.875f * t + 4.0f); gust = std::clamp(0.5f + 0.5f * gust, 0.f, 1.f); // Swept band: lulls murmur at ~60–180 Hz, full gusts open to // ~140–620 Hz. The per-sample alpha is driven by the loop-exact // LFOs, so the sweep itself wraps seamlessly too. const float aHi = lpAlpha(180.f + 440.f * gust, sr); const float aLo = lpAlpha(60.f + 80.f * gust, sr); const float w = rn(); const float band = hi2(hi1(w, aHi), aHi) - lo2(lo1(w, aLo), aLo); const float whoosh = band * (0.10f + 0.90f * gust * gust); const float flutter = (fl1(w, aFlHi) - fl2(w, aFlLo)) * gust * gust * gust * gust * 0.18f; s[i] = whoosh + flutter; } return normalized(loopable(s, n, extra), 0.5f); } // Engine (spatialised at the stern) + ocean/wind ambience loops, with the // listener following the camera. Degrades to a no-op when no audio device // is available; never constructed in headless --shot capture runs. struct OceanSounds { std::unique_ptr listener; std::unique_ptr engine; std::unique_ptr