diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
| commit | e332019acb312ec64893c26cf3d797d5ce472f26 (patch) | |
| tree | d97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src/clip.js | |
| parent | 99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff) | |
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src/clip.js')
| -rw-r--r-- | public/bluebey-studio/src/clip.js | 174 |
1 files changed, 174 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/clip.js b/public/bluebey-studio/src/clip.js new file mode 100644 index 0000000..d94e8e4 --- /dev/null +++ b/public/bluebey-studio/src/clip.js @@ -0,0 +1,174 @@ +import * as THREE from 'three'; + +/** + * 見えない壁 (the invisible wall): a clipping plane that hides whatever falls + * behind it, so the character can be buried in the wall and only the rest of the + * body shows. + * + * The wall is a *finite* rectangle: an invisible quad that writes depth and no + * colour, drawn before everything else in the opaque pass, so the character + * behind it is culled by the depth test. That is what a real wall does, and it + * is the only way to bound the effect - a `renderer.clippingPlanes` entry is an + * infinite half-space, so it can never be limited to a rectangle. + * + * Nothing needs to agree per-material: the depth buffer does the work, so the + * body, the outline hulls, the face plates, the ink pass and the offscreen + * renders an export uses are all hidden by the same wall. + * + * `wallPlane` is pure (no three.js maths), so the geometry can be unit-tested + * without a renderer. + */ + +const DEG = Math.PI / 180; + +/** + * A hair toward the kept side. The guide sits exactly on the cut, and a plane + * on its own boundary is half inside the discarded half - nudging it along the + * normal keeps all of it on the visible side. + */ +const GUIDE_EPSILON = 0.002; + +/** + * The plane a wall setting lies in. + * + * The wall is a *finite* quad, so this no longer decides which half of space is + * hidden - the quad's own depth does that. What `apply` needs from here is the + * normal, which is the direction the quad faces (and so the direction the wall + * lies along), plus the constant of the plane through the point it sits on. + * + * By default the wall lies in the XY plane through `(x, y, z)`; `yaw` turns it + * about Y and `tilt` leans it about X afterwards. + * + * @param {{x?:number,y?:number,z?:number,yaw?:number,tilt?:number}} [wall] + * @returns {{normal:[number,number,number], constant:number}} + */ +export function wallPlane(wall = {}) { + const yaw = (wall.yaw ?? 0) * DEG; + const tilt = (wall.tilt ?? 0) * DEG; + const cosYaw = Math.cos(yaw); + const sinYaw = Math.sin(yaw); + const cosTilt = Math.cos(tilt); + const sinTilt = Math.sin(tilt); + + // Start from +Z, turn about Y, then lean about the (already turned) X axis. + let nx = sinYaw; + let ny = -cosYaw * sinTilt; + let nz = cosYaw * cosTilt; + + const length = Math.hypot(nx, ny, nz) || 1; + nx /= length; + ny /= length; + nz /= length; + + // Constant so the plane passes through (x, y, z): dot(n, p) + c = 0. + const constant = -(nx * (wall.x ?? 0) + ny * (wall.y ?? 0) + nz * (wall.z ?? 0)); + return { normal: [nx, ny, nz], constant }; +} + +/** The faint plane shown while placing the wall. Hidden from every export. */ +/** + * The wall itself: an invisible, *finite* quad that writes depth but no colour. + * + * It is drawn before everything else in the opaque pass (`renderOrder`), so the + * character behind it is culled by the depth test. That is what a real wall does, + * and - unlike a clip plane, which is an infinite half-space - it only hides what + * the rectangle actually covers. So the size sliders are the wall's real size. + */ +function makeWall() { + const material = new THREE.MeshBasicMaterial({ + colorWrite: false, + side: THREE.DoubleSide, + toneMapped: false, + }); + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material); + mesh.name = 'wall'; + // Before everything, including the silhouette-only hulls (renderOrder -1), so + // its depth hides a nose buried in the wall (see styles.js hullMaterialFor). + mesh.renderOrder = -2; + mesh.visible = false; + mesh.castShadow = false; + mesh.receiveShadow = false; + return mesh; +} + +function makeGuide() { + const geometry = new THREE.PlaneGeometry(1, 1); + const material = new THREE.MeshBasicMaterial({ + color: 0x8a4fe0, + transparent: true, + opacity: 0.16, + side: THREE.DoubleSide, + depthWrite: false, + toneMapped: false, + }); + const mesh = new THREE.Mesh(geometry, material); + mesh.name = 'wall-guide'; + mesh.userData.isHelper = true; + mesh.visible = false; + + // An edge, so the plane's extent is legible even where the fill is faint. + const edge = new THREE.LineSegments( + new THREE.EdgesGeometry(geometry), + new THREE.LineBasicMaterial({ color: 0x8a4fe0, transparent: true, opacity: 0.55, toneMapped: false }), + ); + edge.userData.isHelper = true; + mesh.add(edge); + return mesh; +} + +export function createClipper({ scene, renderer, model }) { + const plane = new THREE.Plane(); + const guide = makeGuide(); + const occluder = makeWall(); + scene.add(guide); + scene.add(occluder); + // A little larger than the character: big enough to read as a wall, small + // enough not to cover the whole viewport. + const guideSpan = Math.max(model.size.x, model.size.y, model.size.z) * 1.5; + + /** + * @param {object} wall `state.render.wall` + * + * The guide is drawn only when `wall.guide` is on, which is also when the plane + * can be grabbed in the viewport. Off, the wall is invisible: the cut still + * applies, so you can see the character half-hidden with nothing in the way. + */ + function apply(wall) { + if (wall?.on !== true) { + occluder.visible = false; + guide.visible = false; + return; + } + + const { normal, constant } = wallPlane(wall); + plane.set(new THREE.Vector3(normal[0], normal[1], normal[2]), constant); + + const point = new THREE.Vector3(wall.x ?? 0, wall.y ?? 0, wall.z ?? 0); + const span = guideSpan * Math.max(0.05, wall.size ?? 1); + // The wall and its guide are the same rectangle, turned to face along the + // plane's normal: the wall is the occluder, the guide is the tinted copy that + // is shown only while placing. + for (const mesh of [occluder, guide]) { + mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), plane.normal); + mesh.scale.setScalar(span); + } + occluder.position.copy(point); + occluder.visible = true; + guide.position.copy(point).addScaledVector(plane.normal, GUIDE_EPSILON); + guide.visible = wall.guide === true; + } + + return { + plane, + guide, + occluder, + apply, + dispose() { + for (const mesh of [guide, occluder]) { + mesh.geometry.dispose(); + mesh.material.dispose(); + mesh.removeFromParent(); + } + }, + }; +} |
