diff --git a/flow-state/src/venues/ride.js b/flow-state/src/venues/ride.js index 13705be..1cd7d02 100644 --- a/flow-state/src/venues/ride.js +++ b/flow-state/src/venues/ride.js @@ -12,6 +12,50 @@ import { actorToGeometry } from '../actors/meshes.js'; import { buildSky, paintSky } from './sky.js'; +/** + * Give a bodywork material a spherical sampling of the viz texture, so the + * car mirrors its own video like paint. + * + * three r181 compiles a 2D envMap as a cube sampler, so the native envMap + * path cannot sample the shared viz texture (and re-targeting the texture's + * mapping would break the radio/billboard, which need plain UVs). Instead + * this reflects the view-space normal about the view direction, maps the + * direction to sphere UVs, and adds the sample to the emissive term — the + * diffuse palette colour stays untouched, which is what keeps it reading + * as bodywork rather than as a screen. Analytic in (camera, normal): same + * frame always renders the same reflection, so seek === playback holds. + * + * The texture uniform starts null (three binds its empty texture: black) + * and bindVizTexture fills it from the compositor's vizTarget every frame. + */ +function vizEnvMaterial(mat, instance, strength) { + mat.userData.vizEnv = { value: null }; + mat.userData.vizEnvBase = strength; + mat.userData.vizEnvStrength = { value: strength }; + mat.onBeforeCompile = (shader) => { + shader.uniforms.u_vizEnv = mat.userData.vizEnv; + shader.uniforms.u_vizEnvStrength = mat.userData.vizEnvStrength; + shader.fragmentShader = ( + 'uniform sampler2D u_vizEnv;\nuniform float u_vizEnvStrength;\n' + + shader.fragmentShader + ).replace( + '#include ', + `#include + { + vec3 vDir = normalize( vViewPosition ); + vec3 rDir = reflect( vDir, normalize( normal ) ); + vec2 sUv = vec2( + atan( rDir.z, rDir.x ) * 0.15915494 + 0.5, + asin( clamp( rDir.y, -1.0, 1.0 ) ) * 0.31830988 + 0.5 ); + totalEmissiveRadiance += texture2D( u_vizEnv, sUv ).rgb * u_vizEnvStrength; + }` + ); + }; + mat.customProgramCacheKey = () => 'ride-vizenv'; + (instance.envMats || (instance.envMats = [])).push(mat); + return mat; +} + export const ride = { name: 'Ride', archetype: 'ride', @@ -42,22 +86,27 @@ export const ride = { // Venue RTT: the compositor calls this with the finished viz texture after // compositing the viz stack into vizTarget. The radio screen samples it, // so the viz plays ON the dash rather than composited over the venue — - // and so does the windshield below and the roadside billboard when the - // seed placed one. + // and so does the roadside billboard when the seed placed one. The + // bodywork and cabin glass sample the same texture spherically (see + // vizEnvMaterial below), so the car mirrors its own video like paint. bindVizTexture(instance, texture) { if (!instance || !texture) return; if (instance.radioMat && instance.radioMat.emissiveMap !== texture) { instance.radioMat.emissiveMap = texture; instance.radioMat.needsUpdate = true; } - if (instance.shieldMat && instance.shieldMat.emissiveMap !== texture) { - instance.shieldMat.emissiveMap = texture; - instance.shieldMat.needsUpdate = true; - } if (instance.billMat && instance.billMat.emissiveMap !== texture) { instance.billMat.emissiveMap = texture; instance.billMat.needsUpdate = true; } + // Reflection feed: same texture object the screens sample — the + // injected chunk does its own spherical UVs, so the shared texture's + // UV mapping is untouched and the screens keep working. + for (const m of instance.envMats || []) { + if (m.userData.vizEnv && m.userData.vizEnv.value !== texture) { + m.userData.vizEnv.value = texture; + } + } }, // Radio-glow wash for the viz composite — arcs with venueState.light. @@ -344,7 +393,7 @@ export const ride = { skyMesh: sky.mesh, skyMat: sky.mat, crew, ambient, key, tail, - radioMat: null, radioMesh: null, shieldMat: null, shieldMesh: null, billPanel: null, + radioMat: null, radioMesh: null, envMats: null, billPanel: null, bodyMeshes: [], seed, venueSpec: venueSpec || null, @@ -424,6 +473,7 @@ export const ride = { }); } instance.bodyMeshes = []; + instance.envMats = []; while (instance.crew.children.length) instance.crew.remove(instance.crew.children[0]); const rng = mulberry(((vs ? vs.seed : instance.seed) ^ Math.imul(arrangement + 1, 0x9e3779b9)) >>> 0); @@ -440,13 +490,13 @@ export const ride = { for (let i = 0; i < Math.min(3, parts.length); i++) { const src = parts[(i + arrangement) % parts.length]; const col = cc(i); - const m = new T.Mesh(src.geometry, new T.MeshStandardMaterial({ + const m = new T.Mesh(src.geometry, vizEnvMaterial(new T.MeshStandardMaterial({ color: new T.Color(col[0] * 0.9, col[1] * 0.9, col[2] * 0.9), emissive: new T.Color(col[0] * 0.15, col[1] * 0.15, col[2] * 0.15), roughness: 0.3 + styleWeight * 0.15, metalness: 0.3, transparent: opacity < 0.999, opacity, - })); + }), instance, 0.5)); m.userData.partIndex = src.userData.partIndex ?? i; m.scale.set(1.5, 0.55, 2.4); m.position.set(0, -0.15 + i * 0.12, -0.4 + i * 0.35); @@ -462,12 +512,12 @@ export const ride = { const glassCol = cc(3); const cabin = new T.Mesh( new T.BoxGeometry(1.7, 0.4, 2.1), - new T.MeshStandardMaterial({ + vizEnvMaterial(new T.MeshStandardMaterial({ color: new T.Color(0.04, 0.045, 0.06), emissive: new T.Color(glassCol[0] * 0.12, glassCol[1] * 0.12, glassCol[2] * 0.14), roughness: 0.15, metalness: 0.7, transparent: opacity < 0.999, opacity, - })); + }), instance, 0.9)); cabin.name = 'cabin'; cabin.position.set(0, 0.5, -0.2); instance.car.add(cabin); @@ -533,65 +583,10 @@ export const ride = { instance.radioMesh = radio; instance.bodyMeshes.push(radio); - // Roof display: the viz at a size worth looking at. A wraparound - // glass band over the cabin, same emissiveMap feed as the radio — - // the dash screen is for the crew, this is the one the chase - // camera actually sees. Two placements were tried first: a raked - // windshield across the cabin front (edge-on and invisible from - // the side angles the orbit favours) and a low wraparound band - // (buried inside tall procedural bodies — the chassis varies per - // track and no fixed glass clears all of it). Above the roofline - // nothing occludes it from any chase angle, and the curve still - // presents a face to the lens everywhere: abstract viz has no - // handedness, so the mirrored rear view costs nothing. - const shieldMat = new T.MeshStandardMaterial({ - color: new T.Color(0.02, 0.02, 0.02), - emissive: new T.Color(1, 1, 1), - emissiveIntensity: 1.0, - roughness: 0.4, metalness: 0.2, - side: T.DoubleSide, - }); - // Cylinder side: x = r·sinθ, z = r·cosθ, so θ = π centres the - // band on -z (forward) and ±100° wraps it around both flanks. - // Sized to read at chase distance: the car is ~2.5 units filmed - // from 4-6 away, so anything smaller is a rumour at card scale. - // Floated above the body's highest point + margin, measured off - // what was just built — the chassis varies per track and a fixed - // height gets buried (measured). Crew heads (≤0.92 with bob, - // built below) are floored in. Static per arrangement: the key - // carries no per-section seeds, so it never moves mid-video. - instance.car.updateMatrixWorld(true); - const measBox = new T.Box3(); - for (const n of ['chassis', 'cabin']) { - const o = instance.car.getObjectByName(n); - if (o) measBox.expandByObject(o); - } - const bandY = Math.max( - 1.25, (measBox.isEmpty() ? 0.7 : measBox.max.y) + 0.22); - const shield = new T.Mesh( - new T.CylinderGeometry(1.1, 1.1, 0.6, 24, 1, true, Math.PI - 1.745, 3.49), - shieldMat); - shield.position.set(0, bandY, -0.32); - shield.name = 'windshield'; - instance.car.add(shield); - instance.shieldMat = shieldMat; - instance.shieldMesh = shield; - instance.bodyMeshes.push(shield); - // Masts seating the band on the cabin top. Sized from the - // measured band height so they always meet it. - const pillarMat = new T.MeshStandardMaterial({ - color: new T.Color(0.05, 0.05, 0.06), roughness: 0.6, metalness: 0.3, - }); - { - const postH = Math.max(0.1, (bandY - 0.3) - 0.66); - for (const side of [-1, 1]) { - const pillar = new T.Mesh(new T.BoxGeometry(0.05, postH, 0.05), pillarMat); - pillar.position.set(side * 0.7, 0.66 + postH / 2, -1.17); - pillar.name = 'pillar'; - instance.car.add(pillar); - instance.bodyMeshes.push(pillar); - } - } + // (no separate display mesh: the bodywork itself mirrors the viz — + // see vizEnvMaterial on the chassis and cabin above. A wraparound + // glass band was tried here and read as bent-glass weirdness, and + // any fixed glass risks burial inside tall procedural bodies.) // Headlight cones: two small translucent wedges so chase mode // reads. Kept short and faint — long cones filled the frame. @@ -857,12 +852,14 @@ export const ride = { instance.radioMat.emissive.setRGB(1, 1, 1); instance.radioMat.emissiveIntensity = (params.light || 0.85) * (0.7 + l * 0.7); } - // Windshield glow, same feed, slightly restrained — it is 4x the - // radio's area and would blow out at the same intensity. - if (instance.shieldMat) { + // Reflection strength follows the section light with the radio; the + // IMAGE is the viz texture, sampled spherically per material. + if (instance.envMats) { const l = vState ? vState.light : 1; - instance.shieldMat.emissive.setRGB(1, 1, 1); - instance.shieldMat.emissiveIntensity = (params.light || 0.85) * (0.55 + l * 0.55); + const k = (params.light || 0.85) * (0.55 + l * 0.55); + for (const m of instance.envMats) { + if (m.userData.vizEnvStrength) m.userData.vizEnvStrength.value = m.userData.vizEnvBase * k; + } } // Ground follows horizon like the stage field. instance.road.position.y = -1.15 + (spaceHorizon - 0.5) * 0.6;