aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/look.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
commitbc2821adb11a30244fc4663f4fafb756877d9508 (patch)
treefea8d30d2eb1e67f895097dbf7ec4d152361243e /bluebey-studio/src/look.js
parent0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff)
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'bluebey-studio/src/look.js')
-rw-r--r--bluebey-studio/src/look.js184
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();
+ },
+ };
+}