Ride: mirror the viz on the bodywork instead of a glass band

The wraparound band read as bent-glass weirdness. Replaced with environmental spherical projection: chassis and cabin materials sample the shared viz texture by reflected view direction (onBeforeCompile injection, since this three version compiles 2D envMaps as cube samplers), added to emissive so palette bodywork color is retained. Verified on the 10x3 grid: patterning tracks the viz per cell while base colors hold.
This commit is contained in:
Dejvino
2026-09-20 20:45:03 +02:00
parent a0665d75df
commit 2704831047
+72 -75
View File
@@ -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 <emissivemap_fragment>',
`#include <emissivemap_fragment>
{
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;