aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/clip.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/clip.js')
-rw-r--r--public/bluebey-studio/src/clip.js174
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();
+ }
+ },
+ };
+}