aboutsummaryrefslogtreecommitdiffhomepage
path: root/public
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-03 15:16:11 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-03 15:16:11 +0900
commit20e722e5ab241d0a2153a3fdcff8fc8fddf1361e (patch)
treeecc8ddeb0b7ce4e47f30d43c5b77d30c7540eb02 /public
parentc6d1df09c66bcfe8388651866d26ed5d05b4d2d2 (diff)
public/bluebey-studio: 見えない壁を2枚にし、ぶるべーに追従させる
見えない壁は2枚まで置けるようにした(壁1・壁2)。板の位置はぶるべーからの相対で持ち、ぶるべーを動かすと壁も一緒に動くようにした。 地面(影を受ける板)が深度を書かないようにし、ぶるべーが地面より下に来ても体やテクスチャが隠れないようにした。
Diffstat (limited to 'public')
-rw-r--r--public/bluebey-studio/src/clip.js92
-rw-r--r--public/bluebey-studio/src/main.js73
-rw-r--r--public/bluebey-studio/src/panel.js81
-rw-r--r--public/bluebey-studio/src/presets.js38
4 files changed, 178 insertions, 106 deletions
diff --git a/public/bluebey-studio/src/clip.js b/public/bluebey-studio/src/clip.js
index d94e8e4..b5b03f4 100644
--- a/public/bluebey-studio/src/clip.js
+++ b/public/bluebey-studio/src/clip.js
@@ -117,57 +117,75 @@ function makeGuide() {
}
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;
+ const FROM = new THREE.Vector3(0, 0, 1); // the plane the quad starts in
+
+ function makeSlot() {
+ const occluder = makeWall();
+ const guide = makeGuide();
+ scene.add(guide);
+ scene.add(occluder);
+ return { occluder, guide, normal: new THREE.Vector3(0, 0, 1) };
+ }
+ // Two walls. Each slot keeps its own rectangle, so the two are independent.
+ const slots = [makeSlot(), makeSlot()];
/**
- * @param {object} wall `state.render.wall`
+ * @param {Array<object>} walls up to two `state.render.wall` settings, in slot
+ * order.
+ * @param {{x?:number,y?:number,z?:number}} [offset] the character's own
+ * translation. A wall's position is stored *relative to the character*, so
+ * adding this makes the walls travel with the body when it is moved.
*
- * The guide is drawn only when `wall.guide` is on, which is also when the plane
+ * The guide is drawn only when `wall.guide` is on, which is also when the wall
* 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;
+ function apply(walls, offset) {
+ const list = Array.isArray(walls) ? walls : [walls];
+ const ox = offset?.x ?? 0;
+ const oy = offset?.y ?? 0;
+ const oz = offset?.z ?? 0;
+ slots.forEach((slot, index) => {
+ const wall = list[index];
+ if (!wall || wall.on !== true) {
+ slot.occluder.visible = false;
+ slot.guide.visible = false;
+ return;
+ }
+ const x = (wall.x ?? 0) + ox;
+ const y = (wall.y ?? 0) + oy;
+ const z = (wall.z ?? 0) + oz;
+ const { normal } = wallPlane({ yaw: wall.yaw, tilt: wall.tilt, x, y, z });
+ slot.normal.set(normal[0], normal[1], normal[2]);
+ 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 [slot.occluder, slot.guide]) {
+ mesh.quaternion.setFromUnitVectors(FROM, slot.normal);
+ mesh.scale.setScalar(span);
+ }
+ slot.occluder.position.set(x, y, z);
+ slot.occluder.visible = true;
+ slot.guide.position.set(x, y, z).addScaledVector(slot.normal, GUIDE_EPSILON);
+ slot.guide.visible = wall.guide === true;
+ });
}
return {
- plane,
- guide,
- occluder,
+ guides: slots.map((slot) => slot.guide),
+ occluders: slots.map((slot) => slot.occluder),
apply,
dispose() {
- for (const mesh of [guide, occluder]) {
- mesh.geometry.dispose();
- mesh.material.dispose();
- mesh.removeFromParent();
+ for (const slot of slots) {
+ for (const mesh of [slot.guide, slot.occluder]) {
+ mesh.geometry.dispose();
+ mesh.material.dispose();
+ mesh.removeFromParent();
+ }
}
},
};
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index c7e6501..f2434b9 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -128,9 +128,12 @@ async function init() {
const ambient = new THREE.HemisphereLight(0xffffff, 0xd9d0f2, 0.9);
scene.add(key, key.target, fill, ambient);
+ // A shadow catcher, not a solid floor: `depthWrite: false` keeps the plane from
+ // ever hiding the character, so moving ぶるべー below it no longer makes the body
+ // (or its textures) vanish - the shadow still falls on it, but it never occludes.
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(60, 60),
- new THREE.ShadowMaterial({ opacity: 0.22, transparent: true }),
+ new THREE.ShadowMaterial({ opacity: 0.22, transparent: true, depthWrite: false }),
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
@@ -389,6 +392,14 @@ async function init() {
// A clip plane that hides part of the character, so it can look half buried
// in a wall (see src/clip.js). Applied in `refresh`, with the render settings.
const clip = createClipper({ scene, renderer, model });
+ // A wall's position is stored relative to the character, so every time the
+ // walls are applied they are shifted by the body's own offset - that is what
+ // makes them travel with ぶるべー when it is moved.
+ const characterOffset = () => {
+ const root = state.pose?.root;
+ return { x: root?.[0] ?? 0, y: root?.[1] ?? 0, z: root?.[2] ?? 0 };
+ };
+ const applyWalls = () => clip.apply([state.render.wall, state.render.wall2], characterOffset());
// --------------------------------------------------------------- backdrop
const backdrop = createBackdrop({
@@ -755,7 +766,7 @@ async function init() {
let propDrag = null;
// The 見えない壁 uses the same machinery: `wallDrag` keeps the grabbed point's
// offset from the wall's anchor so grabbing a corner does not make it jump.
- const wallDrag = { pointerId: null, offset: new THREE.Vector3() };
+ const wallDrag = { pointerId: null, key: 'wall', offset: new THREE.Vector3() };
/** The pointer in normalised device coordinates, i.e. what a raycaster wants. */
function pointerNdc(event) {
@@ -789,10 +800,19 @@ async function init() {
* so with it off a huge invisible plane cannot swallow every orbit drag.
*/
function wallAt(event) {
- if (state.render.wall?.on !== true || state.render.wall.guide !== true) return null;
propRaycaster.setFromCamera(pointerNdc(event), view.camera);
- const hits = propRaycaster.intersectObject(clip.guide, false);
- return hits.length ? hits[0].point.clone() : null;
+ let best = null;
+ for (let index = 0; index < clip.guides.length; index += 1) {
+ const key = index === 0 ? 'wall' : 'wall2';
+ const wall = state.render[key];
+ if (wall?.on !== true || wall?.guide !== true) continue;
+ const hits = propRaycaster.intersectObject(clip.guides[index], false);
+ if (!hits.length) continue;
+ if (!best || hits[0].distance < best.distance) {
+ best = { index, key, point: hits[0].point.clone() };
+ }
+ }
+ return best;
}
function onPropPointerDown(event) {
@@ -831,10 +851,18 @@ async function init() {
event.stopPropagation();
event.preventDefault();
view.camera.getWorldDirection(propNormal);
- propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), onWall);
- const wall = state.render.wall;
+ propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), onWall.point);
+ const wall = state.render[onWall.key] ?? (state.render[onWall.key] = defaultState().render[onWall.key]);
+ const offset = characterOffset();
wallDrag.pointerId = event.pointerId;
- wallDrag.offset.set(wall.x ?? 0, wall.y ?? 0, wall.z ?? 0).sub(onWall);
+ wallDrag.key = onWall.key;
+ // Keep the grab point's offset *in the wall's own (character-relative) frame*,
+ // so grabbing a corner does not make the wall jump.
+ wallDrag.offset.set(
+ (wall.x ?? 0) - (onWall.point.x - offset.x),
+ (wall.y ?? 0) - (onWall.point.y - offset.y),
+ (wall.z ?? 0) - (onWall.point.z - offset.z),
+ );
stage.style.cursor = 'grabbing';
app.needsRender = true;
}
@@ -848,14 +876,15 @@ async function init() {
event.stopPropagation();
propRaycaster.setFromCamera(pointerNdc(event), view.camera);
if (!propRaycaster.ray.intersectPlane(propPlane, propPoint)) return;
- const next = propPoint.add(wallDrag.offset);
- const wall = state.render.wall;
- // The same ranges the panel's sliders offer, so a drag and a number always
- // describe the same place.
- wall.x = round2(clampNumber(next.x, -4, 4));
- wall.y = round2(clampNumber(next.y, -2, 4));
- wall.z = round2(clampNumber(next.z, -4, 4));
- clip.apply(wall);
+ const offset = characterOffset();
+ const wall = state.render[wallDrag.key] ?? (state.render[wallDrag.key] = defaultState().render[wallDrag.key]);
+ // Back into the wall's character-relative frame, then the same ranges the
+ // panel's sliders offer, so a drag and a number always describe the same
+ // place.
+ wall.x = round2(clampNumber(propPoint.x - offset.x + wallDrag.offset.x, -4, 4));
+ wall.y = round2(clampNumber(propPoint.y - offset.y + wallDrag.offset.y, -2, 4));
+ wall.z = round2(clampNumber(propPoint.z - offset.z + wallDrag.offset.z, -4, 4));
+ applyWalls();
app.needsRender = true;
return;
}
@@ -1055,7 +1084,7 @@ async function init() {
return [
ground,
look.contact,
- clip.guide,
+ ...clip.guides,
rig.helper,
model.parts.eyeMesh,
model.parts.mouthMesh,
@@ -1068,7 +1097,7 @@ async function init() {
// labels behind it are culled as well. Excluded, the leaves and the nose
// buried in the wall kept their outlines, because the labels are read before
// its depth is applied.
- outline.occlude([clip.occluder]);
+ outline.occlude(clip.occluders);
/**
* The parts the screen-space pass draws, with the label each one writes.
@@ -1459,7 +1488,7 @@ async function init() {
look.apply(state.render, { line });
applyProps();
// 見えない壁: the one setting that changes what is drawn rather than how.
- clip.apply(state.render.wall);
+ applyWalls();
const azimuth = state.render.lightAzimuth * DEG;
const elevation = state.render.lightElevation * DEG;
@@ -1762,14 +1791,14 @@ async function init() {
/** Hide the gizmo and helpers so they never end up in an export. */
async function withHiddenHelpers(fn) {
const wasVisible = rig.helper.visible;
- const guideWasVisible = clip.guide.visible;
+ const guidesWereVisible = clip.guides.map((guide) => guide.visible);
rig.helper.visible = false;
- clip.guide.visible = false;
+ clip.guides.forEach((guide) => { guide.visible = false; });
try {
return await fn();
} finally {
rig.setGizmoVisible(wasVisible);
- clip.guide.visible = guideWasVisible;
+ clip.guides.forEach((guide, index) => { guide.visible = guidesWereVisible[index]; });
app.needsRender = true;
}
}
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index 72718bc..e9764c1 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -2066,50 +2066,63 @@ export function buildPanel(app, root) {
/* ------------------------------------------------------------- 見えない壁 */
- // 見えない壁: place a clip plane and bury the character in it, so only the
- // part in front of the wall still shows. It is the same cut the camera's own
- // far plane makes, put where you want it (see src/clip.js).
+ // 見えない壁: place up to two finite "boards" and bury the character in them, so
+ // only the part in front still shows. A wall's position is kept *relative to the
+ // character*, so moving ぶるべー carries its walls along (see src/clip.js).
const wallSection = details(stageSection.body, '見えない壁');
- const wallConfig = () => (state.render.wall = state.render.wall ?? defaultState().render.wall);
wallSection.add(hint('見えない板(有限の壁)を置くと、その向こう側が隠れます。'
+ 'ぶるべーを壁に埋め込むと、体の一部だけが見えるようになります。'
+ + '板は2枚まで置けます。板の位置はぶるべーからの相対なので、ぶるべーを動かすと壁も一緒に動きます。'
+ '板そのものは透明です(下の「板を表示」をオンにしたときだけ見えます)。'));
- const wallToggle = check({
- label: '見えない壁で一部を隠す',
- value: false,
- onChange: (value) => { wallConfig().on = value; app.actions.refresh('render'); sync(); },
- });
- const wallSliders = {
- x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { wallConfig().x = v; app.actions.refresh('render'); sync(); } }),
- y: slider({ label: 'たかさ(Y)', min: -2, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { wallConfig().y = v; app.actions.refresh('render'); sync(); } }),
- z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { wallConfig().z = v; app.actions.refresh('render'); sync(); } }),
- yaw: slider({ label: '向き(左右)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { wallConfig().yaw = v; app.actions.refresh('render'); sync(); } }),
- tilt: slider({ label: '傾き(前後)', min: -90, max: 90, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { wallConfig().tilt = v; app.actions.refresh('render'); sync(); } }),
- size: slider({ label: '大きさ', min: 0.2, max: 8, step: 0.1, value: 1, format: (v) => v.toFixed(1), onInput: (v) => { wallConfig().size = v; app.actions.refresh('render'); sync(); } }),
- };
- wallSection.add(wallToggle.el);
- for (const widget of Object.values(wallSliders)) wallSection.add(widget.el);
+ const WALL_SLOTS = [
+ { key: 'wall', label: '壁 1' },
+ { key: 'wall2', label: '壁 2' },
+ ];
- // The wall writes depth but no colour, so it hides what is behind it without
- // being visible. The guide is the tinted copy of that same rectangle, shown
- // only while placing - and it is also the switch that says "I am placing the
- // wall now", so with it off the wall cannot swallow an orbit drag.
- const wallGuideToggle = check({
- label: '板を表示(ビューポートでドラッグして動かせます)',
- value: false,
- onChange: (value) => { wallConfig().guide = value; app.actions.refresh('render'); sync(); },
+ const wallGroups = WALL_SLOTS.map(({ key, label }) => {
+ const box = details(wallSection.body, label);
+ const cfg = () => (state.render[key] = state.render[key] ?? defaultState().render[key]);
+ const toggle = check({
+ label: `${label}で一部を隠す`,
+ value: false,
+ onChange: (value) => { cfg().on = value; app.actions.refresh('render'); sync(); },
+ });
+ box.add(controlRow(null, toggle.el, { wide: true }));
+ const sliders = {
+ x: slider({ label: 'よこ(X)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().x = v; app.actions.refresh('render'); sync(); } }),
+ y: slider({ label: 'たかさ(Y)', min: -2, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().y = v; app.actions.refresh('render'); sync(); } }),
+ z: slider({ label: 'おくゆき(Z)', min: -4, max: 4, step: 0.05, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { cfg().z = v; app.actions.refresh('render'); sync(); } }),
+ yaw: slider({ label: '向き(左右)', min: -180, max: 180, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { cfg().yaw = v; app.actions.refresh('render'); sync(); } }),
+ tilt: slider({ label: '傾き(前後)', min: -90, max: 90, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { cfg().tilt = v; app.actions.refresh('render'); sync(); } }),
+ size: slider({ label: '大きさ', min: 0.2, max: 8, step: 0.1, value: 1, format: (v) => v.toFixed(1), onInput: (v) => { cfg().size = v; app.actions.refresh('render'); sync(); } }),
+ };
+ for (const widget of Object.values(sliders)) box.add(widget.el);
+
+ // The wall writes depth but no colour, so it hides what is behind it without
+ // being visible. The guide is the tinted copy of that same rectangle, shown
+ // only while placing - and it is also the switch that says "I am placing the
+ // wall now", so with it off the wall cannot swallow an orbit drag.
+ const guide = check({
+ label: '板を表示(ビューポートでドラッグして動かせます)',
+ value: false,
+ onChange: (value) => { cfg().guide = value; app.actions.refresh('render'); sync(); },
+ });
+ box.add(controlRow(null, guide.el, { wide: true }));
+ return { key, label, toggle, sliders, guide };
});
- wallSection.add(controlRow(null, wallGuideToggle.el, { wide: true }));
+
wallSection.add(hint('板は「大きさ」の四角い壁そのもので、**その後ろ側が隠れます**'
+ '(カメラから見て板の後ろになる部分が隠れるので、カメラを回すと見え方も変わります)。'
- + '「板を表示」を切ると板は完全に透明になり、カメラ操作のじゃまもしません。'));
+ + '「板を表示」を切ると板は完全に透明になり、カメラ操作のじゃまもしません。'
+ + '「板を表示」した板は、ビューポートでドラッグしても動かせます。'));
wallSection.add(buttons({
items: [{
id: 'wallReset',
label: '見えない壁をリセット',
onClick: () => {
state.render.wall = defaultState().render.wall;
+ state.render.wall2 = defaultState().render.wall2;
app.actions.refresh('render');
sync();
},
@@ -2622,10 +2635,12 @@ export function buildPanel(app, root) {
});
syncers.push(() => {
- const wall = state.render?.wall ?? defaultState().render.wall;
- wallToggle.set(wall.on === true);
- for (const [key, widget] of Object.entries(wallSliders)) widget.set(wall[key] ?? (key === 'size' ? 1 : 0));
- wallGuideToggle.set(wall.guide === true);
+ for (const group of wallGroups) {
+ const wall = state.render?.[group.key] ?? defaultState().render[group.key];
+ group.toggle.set(wall.on === true);
+ for (const [key, widget] of Object.entries(group.sliders)) widget.set(wall[key] ?? (key === 'size' ? 1 : 0));
+ group.guide.set(wall.guide === true);
+ }
});
syncers.push(() => {
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index 1130a67..12b14fd 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -40,6 +40,26 @@ function captionDefaults() {
};
}
+/**
+ * 見えない壁 (the invisible wall) settings. The studio offers two, so a corner (or
+ * two opposite cuts) can be made. A wall's position is kept *relative to the
+ * character*, so moving ぶるべー carries its walls along (see src/clip.js).
+ */
+function wallDefaults() {
+ return {
+ on: false,
+ x: 0, y: 0, z: 0, // a point the plane passes through, relative to the character
+ yaw: 0, // turn about Y, degrees
+ tilt: 0, // lean about X, degrees
+ // The guide is the only part of the wall that is ever *drawn* (the wall
+ // itself writes depth but no colour, so it hides what is behind it
+ // without being visible). `guide` shows that rectangle while placing,
+ // which is also when it can be dragged in the viewport.
+ guide: false,
+ size: 1, // the wall's size (a real width and height, not a clip)
+ };
+}
+
export function defaultState() {
return {
version: 1,
@@ -142,21 +162,11 @@ export function defaultState() {
// nothing is drawn along the meeting. Cleaner, but the leaves and
// the body merge into one white shape.
leafBodyLine: true,
- // 見えない壁 (the invisible wall). A placeable clipping plane that hides
- // whatever is behind it, so the character can be buried in the wall and
+ // 見えない壁 (the invisible wall). Up to two placeable rectangles that hide
+ // whatever is behind them, so the character can be buried in a wall and
// only the rest of the body shows. See src/clip.js.
- wall: {
- on: false,
- x: 0, y: 0, z: 0, // a point the plane passes through (world units)
- yaw: 0, // turn about Y, degrees
- tilt: 0, // lean about X, degrees
- // The guide is the only part of the wall that is ever *drawn* (the wall
- // itself writes depth but no colour, so it hides what is behind it
- // without being visible). `guide` shows that rectangle while placing,
- // which is also when it can be dragged in the viewport.
- guide: false,
- size: 1, // the wall's size (a real width and height, not a clip)
- },
+ wall: wallDefaults(),
+ wall2: wallDefaults(),
},
pose: {
bones: {}, // { boneName: [x, y, z] } in degrees, rest = 0,0,0