import * as THREE from 'three'; import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; /** * The "look" of the scene, as opposed to the character's shape: body colours, * mirroring, shadows and the lighting environment. * * These all live here rather than in `main.js` because they are a *policy* about * how the model should be presented, and they need to be re-applied as a group * whenever any of them changes. `main.js` only has to call `apply()`. * * Two things are worth knowing: * * - Colours are written onto the model's ORIGINAL materials. The flat/toon * style caches its own materials derived from them, so `styles.refreshColors()` * has to be called afterwards or the toon shading keeps the old colour. * * - Shadow softness is `LightShadow.radius` with a PCF soft shadow map, plus a * hand-drawn contact blob for the "ground shadow only" mode. (VSM was tried * for its wider blur and cut the shadow off in a straight line where it met * the feet - see the note in `apply`.) */ export const ENVIRONMENTS = [ { value: 'gradient', label: 'スタジオ(明るい)' }, { value: 'room', label: '室内(自然な反射)' }, { value: 'none', label: 'なし(のっぺり)' }, ]; /** Material name -> which entry of `render.colors` it takes. */ const PART_COLORS = { blb: 'body', Hand: 'accent', Foots: 'feet', Nose: 'nose', Leaf: 'leaf', Vein: 'vein', }; const clamp01 = (value) => Math.min(1, Math.max(0, Number(value) || 0)); /** * A soft blob that sits under the feet: the shadow a toy would cast on a table. * It is a plain plane with a radial gradient, so it is cheap and it works in the * line-art styles too, where the real shadow map is switched off. */ function makeContactShadow(size) { const canvas = document.createElement('canvas'); canvas.width = 128; canvas.height = 128; const ctx = canvas.getContext('2d'); const gradient = ctx.createRadialGradient(64, 64, 4, 64, 64, 62); gradient.addColorStop(0, 'rgba(0,0,0,0.55)'); gradient.addColorStop(0.55, 'rgba(0,0,0,0.28)'); gradient.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 128, 128); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false, toneMapped: false, }); const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material); mesh.rotation.x = -Math.PI / 2; mesh.renderOrder = -0.5; mesh.userData.isHelper = true; mesh.name = 'contact-shadow'; mesh.scale.setScalar(Math.max(1, size.x) * 1.15); return mesh; } export function createLook({ renderer, scene, styles, model, ground, key, character, gradientEnvironment }) { const pmrem = new THREE.PMREMGenerator(renderer); const roomEnvironment = pmrem.fromScene(new RoomEnvironment(), 0.06).texture; const contact = makeContactShadow(model.size); scene.add(contact); const environmentFor = (name) => { if (name === 'room') return roomEnvironment; if (name === 'none') return null; return gradientEnvironment ?? null; }; function applyColors(colors) { if (!colors) return; let touched = false; for (const mesh of model.parts.body) { const material = styles.originals.get(mesh) ?? mesh.material; const part = PART_COLORS[material?.name]; if (!part || !colors[part]) continue; if (!material.color) material.color = new THREE.Color(); material.color.set(colors[part]); touched = true; } // The toon materials are cached copies, so they need the same edit. if (touched) styles.refreshColors?.(); return touched; } return { contact, /** * @param {object} render `state.render` * @param {{ line?: boolean }} [context] `line` is true in the line-art * styles, where shadows and reflections are deliberately switched off. */ apply(render, { line = false } = {}) { if (!render) return; applyColors(render.colors); // Mirroring by negative scale is safe: the renderer flips the winding for // a negative determinant, and the normals go through the inverse-transpose. character.scale.x = render.mirror ? -1 : 1; const wantsShadow = render.shadow !== false && !line; const blobOnly = render.contactShadow === true; ground.visible = wantsShadow && !blobOnly; ground.material.opacity = clamp01(render.shadowOpacity ?? 0.22); key.castShadow = wantsShadow && !blobOnly; key.shadow.needsUpdate = true; // The blob is drawn in BOTH modes. On its own it *is* the shadow (the // "ground shadow only" setting); with the shadow map on it fills the gap // where the body hides its own shadow right at the feet, which otherwise // reads as "the shadow is cut off". It is the only thing keeping the // character looking like it is standing on the floor rather than above it. contact.visible = wantsShadow; contact.material.opacity = clamp01((render.shadowOpacity ?? 0.22) * (blobOnly ? 2.6 : 1.6)); const spread = Math.max(0.2, (model.size.x * 1.15) / Math.max(0.05, render.shadowSoftness ?? 1.6)); contact.scale.setScalar(Math.max(0.5, model.size.x * 1.35 - spread * 0.25)); contact.position.y = 0.012; const softness = Number(render.shadowSoftness) || 1.6; // PCF, always. three 0.186 removed PCFSoftShadowMap (asking for it warns and // falls back to this anyway), and VSM needs a depth-variance bias that cut // the shadow away in a straight line right at the feet. `radius` is what // softens PCF. if (renderer.shadowMap.type !== THREE.PCFShadowMap) { renderer.shadowMap.type = THREE.PCFShadowMap; // Every shadow-receiving material has to be recompiled for the switch. ground.material.needsUpdate = true; for (const mesh of model.parts.body) { const material = mesh.material; if (Array.isArray(material)) material.forEach((m) => { m.needsUpdate = true; }); else if (material) material.needsUpdate = true; } } // PCF's radius is in *shadow map texels*, and the map covers about 15 world // units, so a radius of 1-2 is invisible. This scales it into something the // eye can see without the sampling turning into noise. if ('radius' in key.shadow) key.shadow.radius = Math.max(0.5, softness * 5); const environment = line ? null : environmentFor(render.environment); if (scene.environment !== environment) scene.environment = environment; if ('environmentIntensity' in scene) { scene.environmentIntensity = Number(render.envIntensity ?? 1); } }, /** The colours a theme would set, as a plain `{ part: '#rrggbb' }`. */ palette(theme) { return { ...(theme?.colors ?? {}) }; }, dispose() { contact.geometry.dispose(); contact.material.map?.dispose(); contact.material.dispose(); contact.removeFromParent(); roomEnvironment.dispose(); pmrem.dispose(); }, }; }