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(); } }, }; }