From 20e722e5ab241d0a2153a3fdcff8fc8fddf1361e Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:16:11 +0900 Subject: public/bluebey-studio: 見えない壁を2枚にし、ぶるべーに追従させる MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 見えない壁は2枚まで置けるようにした(壁1・壁2)。板の位置はぶるべーからの相対で持ち、ぶるべーを動かすと壁も一緒に動くようにした。 地面(影を受ける板)が深度を書かないようにし、ぶるべーが地面より下に来ても体やテクスチャが隠れないようにした。 --- public/bluebey-studio/src/clip.js | 92 +++++++++++++++++++++--------------- public/bluebey-studio/src/main.js | 73 +++++++++++++++++++--------- public/bluebey-studio/src/panel.js | 83 +++++++++++++++++++------------- public/bluebey-studio/src/presets.js | 38 +++++++++------ 4 files changed, 179 insertions(+), 107 deletions(-) (limited to 'public/bluebey-studio/src') 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} 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); - - // 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 WALL_SLOTS = [ + { key: 'wall', label: '壁 1' }, + { key: 'wall2', label: '壁 2' }, + ]; + + 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 -- cgit v1.3.1