diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:09:40 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:09:40 +0900 |
| commit | bc2821adb11a30244fc4663f4fafb756877d9508 (patch) | |
| tree | fea8d30d2eb1e67f895097dbf7ec4d152361243e /bluebey-studio/src/look.js | |
| parent | 0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff) | |
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'bluebey-studio/src/look.js')
| -rw-r--r-- | bluebey-studio/src/look.js | 184 |
1 files changed, 184 insertions, 0 deletions
diff --git a/bluebey-studio/src/look.js b/bluebey-studio/src/look.js new file mode 100644 index 0000000..d755c3b --- /dev/null +++ b/bluebey-studio/src/look.js @@ -0,0 +1,184 @@ +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. + // The blob sits at the feet. It used to be drawn whenever the shadow was on, + // which left a faint ring behind at the origin once the character was moved; + // it is now opt-in (`render.contactBlob`, default off). It is always shown in + // the 「接地影だけ」 mode, where it is the only shadow there is. + contact.visible = wantsShadow && (blobOnly || render.contactBlob === true); + 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(); + }, + }; +} |
