import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { loadModel } from './model.js'; import { Face } from './face.js'; import { Styles, isLineStyle } from './styles.js'; import { Rig } from './rig.js'; import { Animator, createRecorder, startRecording, stopRecording } from './animation.js'; import { defaultState, applyPatch, THEMES } from './presets.js'; import { buildPanel } from './panel.js'; import * as exporter from './exporter.js'; import { toast } from './ui.js'; import { History } from './history.js'; import { createLook } from './look.js'; import { createClipper } from './clip.js'; import { createBackdrop, backdropStyle } from './background.js'; import { drawCaption, layoutCaption } from './caption.js'; import { loadFont, captionFontStack } from './textOutlines.js'; import { buildProp, PROP_DEFAULTS, disposeProp, applyPropText } from './props.js'; import { rollAll, decodeSeed } from './gacha.js'; import { encodeState, decodeState } from './urlState.js'; import { createMouthFlap, levelToMouth } from './mouthFlap.js'; import { createZip } from './zip.js'; import { createScreenOutline, BEHIND_LABEL, SOLID_LABEL, LEAF_LABEL, NOSE_LABEL } from './outline.js'; import { gionSheetUrl, stampRect, imageAspect, imageReady, drawGion, DEFAULT_STAMP_WIDTH } from './gion.js'; const DEG = Math.PI / 180; /** The other direction: what three's Spherical reports is radians. */ const RAD_TO_DEG = 180 / Math.PI; const MODEL_URL = 'assets/bluebey.glb'; /** The caption font is only fetched when a bubble is first used. */ const CAPTION_FONT_TTF = 'assets/fonts/bluebey-caption.ttf'; const CAPTION_FONT_WOFF2 = 'assets/fonts/bluebey-caption.woff2'; /** How far the eyes can swing for a look-at target, in radians. */ const MAX_LOOK_YAW = 0.5; const MAX_LOOK_PITCH = 0.34; /** Undo steps are labelled by the part of the state that changed. */ const SCOPE_LABELS = { all: '変更', render: '見た目', face: '表情', view: 'カメラ・背景', pose: 'ポーズ', caption: 'セリフ', gion: '擬音', }; /** * The single-file build inlines the fonts and the backdrop library, so the same * code reads a data URL there and a file URL in the normal build. */ function assetUrl(file, kind) { const inlined = globalThis.__BLUEBEY_FONTS__ ?? {}; if (kind === 'background') { const backdrops = globalThis.__BLUEBEY_BACKGROUNDS__ ?? {}; return backdrops[file] ?? `assets/backgrounds/${file}.webp`; } if (kind === 'beard') { const beards = globalThis.__BLUEBEY_BEARDS__ ?? {}; return beards[file] ?? `assets/beards/${file}`; } return inlined[file] ?? `assets/fonts/${file}`; } /** * Where the model comes from. The normal build fetches `assets/bluebey.glb`; * the single-file build (tools/build-standalone.mjs) inlines the same bytes as * base64 in `window.__BLUEBEY_MODEL__`, which also lets the page be opened * straight from disk without any web server. */ function resolveModelSource() { const injected = globalThis.__BLUEBEY_MODEL__; if (!injected) return MODEL_URL; if (typeof injected !== 'string') return injected; try { const binary = atob(injected); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); return bytes.buffer; } catch (error) { console.error('inline model could not be decoded, falling back to the file', error); return MODEL_URL; } } const state = defaultState(); const app = { state, model: null, container: null, ready: false, needsRender: true, /** The 擬音 stamp the panel is editing, so the viewport can outline it. */ gionSelected: null, /** Set by init(), used by the panel. */ actions: {}, }; /* ------------------------------------------------------------------ startup */ // The view-rig classes are declared below this point; deferring by a microtask // guarantees the whole module has finished evaluating before anything is built. queueMicrotask(() => { init().catch((error) => { console.error(error); showLoadError(error); }); }); async function init() { const canvas = document.getElementById('view'); // #stage owns the layout; the canvas only fills it (see style.css). const stage = document.getElementById('stage') ?? canvas; const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: true, }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap; const scene = new THREE.Scene(); const environment = makeEnvironment(renderer); const key = new THREE.DirectionalLight(0xffffff, 2.1); key.castShadow = true; key.shadow.mapSize.set(1536, 1536); key.shadow.bias = -0.0008; key.shadow.normalBias = 0.02; const fill = new THREE.DirectionalLight(0xffffff, 0.5); 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(200, 200), new THREE.ShadowMaterial({ opacity: 0.22, transparent: true, depthWrite: false }), ); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground); // The camera lives in two places: `state.view` (what the panel, the saved file // and a shared link use) and the orbit controls (what the mouse moves). Capture // the controls' own position back into the state on every change, so the two // never disagree - otherwise any refresh of the view yanked the camera back to // wherever the panel had last put it. const view = new ViewRig(canvas, () => { view.captureInto(state.view); app.needsRender = true; }); /** * The screen-space outline (see src/outline.js). The normal pass must not see * the ground, the contact shadow or the gizmo, so they are excluded once those * objects exist (see below). */ const outline = createScreenOutline({ renderer, scene, camera: view.camera, width: canvas.clientWidth || 1280, height: canvas.clientHeight || 800, }); outline.exclude([ground]); /** * The drawing buffer must follow the canvas' CSS size. Without this the * browser stretches the default 300x150 buffer across the window and the * whole picture looks coarse (and every exported trace is built from those * few pixels). * * But it must not be *unlimited* either: on a large or high-density display * the buffer can reach many millions of pixels, and with antialiasing, a * shadow map and preserveDrawingBuffer on top, a weak GPU can take seconds * per frame - which looks exactly like "nothing is showing". So the pixel * count is capped and the ratio is lowered automatically on slow hardware. */ const MAX_DRAWING_PIXELS = 2_600_000; const QUALITY_FLOOR = 0.5; let quality = Math.min(window.devicePixelRatio || 1, 2); let autoReductions = 0; function fitQuality(width, height, wanted) { let ratio = wanted; while (ratio > QUALITY_FLOOR && width * height * ratio * ratio > MAX_DRAWING_PIXELS) { ratio = Math.round((ratio - 0.1) * 100) / 100; } return Math.max(QUALITY_FLOOR, ratio); } let lastResize = 0; let resizeBurst = 0; /** Declared early: the viewport can resize before the model has loaded. */ let captionCanvas = null; /** Set while undo/redo replays a snapshot, so it is not recorded again. */ let suspendHistory = false; // ------------------------------------------------------------------- 擬音 // `redrawCaption` runs from the very first `resizeViewport`, so the sheet cache // and the drag handles have to exist before it does (a `const` cannot be read // before it is evaluated). The drawing itself lives further down, next to the // caption overlay it is layered under. const gionImages = new Map(); // sheet name -> loaded Image const gionLoading = new Map(); // sheet name -> in-flight Promise const gionHandles = new Map(); // stamp id -> transparent drag div let gionDrag = null; /** Hands out stamp ids; a counter so removing one never reuses a live id. */ let gionSeq = 0; function resizeViewport() { const width = stage.clientWidth || window.innerWidth; const height = stage.clientHeight || window.innerHeight; // Safety net: if something ever feeds the viewport size back into itself, // stop flipping the drawing buffer on and off and say so. const now = performance.now(); if (now - lastResize < 60) { resizeBurst += 1; if (resizeBurst > 30 && resizeBurst % 30 === 0) { console.warn('[bluebey] too many resizes in a row; ignoring them', resizeBurst); } if (resizeBurst > 30) return; } else { resizeBurst = 0; } lastResize = now; const wanted = Math.min(window.devicePixelRatio || 1, 2); quality = fitQuality(width, height, wanted); renderer.setPixelRatio(quality); renderer.setSize(width, height, false); outline.setSize(width * quality, height * quality); view.resize(); redrawCaption(); app.needsRender = true; } resizeViewport(); new ResizeObserver(() => resizeViewport()).observe(stage); // Losing the WebGL context leaves a permanently blank canvas, so say so // instead of leaving the user staring at nothing. canvas.addEventListener('webglcontextlost', (event) => { event.preventDefault(); showNotice('描画が止まりました(WebGLコンテキストを失いました)。\n' + 'ブラウザのウィンドウを小さくするか、再読み込みしてください。'); }); canvas.addEventListener('webglcontextrestored', () => { app.needsRender = true; }); // -------------------------------------------------------------- the model const model = await loadModel(resolveModelSource(), { onProgress: (ratio) => setLoadingProgress(ratio), }); app.model = model; // The character hangs off a mirror group of its own, so "左右反転" flips the // model without touching the pose offset (`container.position`) or the props. const mirrorGroup = new THREE.Group(); mirrorGroup.add(model.root); const container = new THREE.Group(); container.add(mirrorGroup); scene.add(container); app.container = container; // 小物 live outside the mirror group: they are scenery, not part of the body. const propRoot = new THREE.Group(); scene.add(propRoot); // --- 鼻ちょうちん: a modelled bubble hung under the nose -------------------- // The nose is a *skinned* part of the body, so a child of the nose mesh would // sit at the origin instead of on the face. The whole body (nose included) // rides the `master` bone, so the bubble is parented there, placed at the nose's // rest position in that bone's local frame, and read from the face state each // frame in the render loop. function applySnotBubble(group, snot) { if (!group) return; group.visible = snot?.enabled === true; if (!group.visible) return; // The offsets are applied in the *bone's* frame, along the world axes measured // once at rest, so the bubble travels with the body. Recomputing them against // the live world matrix made it snap back whenever the body had moved. const k = 0.01; // artwork px -> world units, enough to nudge the bubble const { localBase, axes } = group.userData; group.position.copy(localBase) .addScaledVector(axes.x, (snot.offsetX ?? 0) * k) .addScaledVector(axes.y, (snot.offsetY ?? 0) * k) .addScaledVector(axes.z, (snot.offsetZ ?? 0) * k); group.userData.baseScale = Number.isFinite(snot.size) ? snot.size : 1; group.scale.setScalar(group.userData.baseScale); group.userData.material.color.set(snot.color ?? '#dfe8ff'); } const snotBubble = (() => { const nose = model.parts.noseMesh; const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master'); if (!nose || !master || !nose.isSkinnedMesh) return null; model.root.updateMatrixWorld(true); // The nose is a *skinned* mesh, so its raw geometry sits in bind space (at the // back of the model, in fact) - sample the skinned positions instead, which is // where the nose actually ends up. const attr = nose.geometry.attributes.position; const step = Math.max(1, Math.floor(attr.count / 400)); const point = new THREE.Vector3(); const centre = new THREE.Vector3(); let samples = 0; for (let i = 0; i < attr.count; i += step) { point.fromBufferAttribute(attr, i); nose.applyBoneTransform(i, point); centre.add(point); samples += 1; } if (!samples) return null; centre.multiplyScalar(1 / samples); nose.localToWorld(centre); let radius = 0; for (let i = 0; i < attr.count; i += step) { point.fromBufferAttribute(attr, i); nose.applyBoneTransform(i, point); nose.localToWorld(point); radius = Math.max(radius, point.distanceTo(centre)); } if (!Number.isFinite(radius) || radius <= 0) radius = 0.25; const material = new THREE.MeshStandardMaterial({ color: 0xdfe8ff, roughness: 0.45, metalness: 0, transparent: true, opacity: 0.92, }); const group = new THREE.Group(); group.name = 'snot-bubble'; const bubble = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.15, 28, 20), material); group.add(bubble); const bone = master.bone; const baseWorld = centre.clone().add(new THREE.Vector3(radius * 1.9, -radius * 0.85, radius * 0.7)); const localBase = bone.worldToLocal(baseWorld.clone()); // World-axis directions expressed in the bone's frame, captured at rest: the // sliders keep meaning "right / up / towards the viewer" while the bubble rides // with the body. const boneQuat = new THREE.Quaternion(); bone.getWorldQuaternion(boneQuat); const invQuat = boneQuat.clone().invert(); const axes = { x: new THREE.Vector3(1, 0, 0).applyQuaternion(invQuat), y: new THREE.Vector3(0, 1, 0).applyQuaternion(invQuat), z: new THREE.Vector3(0, 0, 1).applyQuaternion(invQuat), }; group.position.copy(localBase); group.userData = { bubble, material, bone, localBase, axes, baseScale: 1 }; group.visible = false; bone.add(group); return group; })(); const snotMeshes = snotBubble ? [...snotBubble.children] : []; applySnotBubble(snotBubble, state.face.eyes.snot); const halfHeight = model.size.y * 0.5; view.frame(model.size, halfHeight); // shadow camera big enough for the whole character const radius = Math.max(model.size.x, model.size.y, model.size.z) * 1.6; const shadowCamera = key.shadow.camera; shadowCamera.left = -radius; shadowCamera.right = radius; shadowCamera.top = radius; shadowCamera.bottom = -radius; shadowCamera.near = 0.5; shadowCamera.far = radius * 9; shadowCamera.updateProjectionMatrix(); key.target.position.set(0, halfHeight, 0); // The light's offset from the character, so the key light (and therefore its // shadow camera) can follow the character around: moving far from the origin // used to leave the shadow behind and cut it off at the shadow camera's edge. const keyOffset = new THREE.Vector3(0, model.size.y * 3, model.size.y * 3); function updateKeyLight() { key.target.position.set(container.position.x, halfHeight + container.position.y, container.position.z); key.position.copy(key.target.position).add(keyOffset); } // ------------------------------------------------------------- controllers const styles = new Styles({ meshes: model.parts.body }); const face = new Face({ eyeMesh: model.parts.eyeMesh, mouthMesh: model.parts.mouthMesh, originals: model.originals, }); const animator = new Animator({ state }); // Beard drawings supplied as files (`assets/beards/.png`): a kind that has // one is drawn from the file instead of the built-in strokes. A missing file is // skipped, so the app works before any are added. void (async () => { // Only the kinds that actually ship a drawing. Add a name here when its file is // added to assets/beards/, so no 404s are logged for the ones without one. const kinds = ['scotch', 'kaiser', 'apron']; const loaded = {}; await Promise.all(kinds.map(async (kind) => { try { const response = await fetch(assetUrl(`${kind}.png`, 'beard')); if (!response.ok) return; loaded[kind] = await createImageBitmap(await response.blob()); } catch { /* no drawing for this kind: the built-in one is used */ } })); if (Object.keys(loaded).length) { face.setBeardImages(loaded); app.needsRender = true; } })(); // ------------------------------------------------------------------- look const look = createLook({ renderer, scene, styles, model, ground, key, character: mirrorGroup, gradientEnvironment: environment, }); // --------------------------------------------------------------- 見えない壁 // 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(), wallYaw(), ); // The character's own turn about Y: the `master` bone's Y (Shift+drag) plus any // body yaw the 見る先 mode added. The walls take this too, so turning the // character with Shift+drag no longer slides the hidden region around. const wallYaw = () => { const masterY = state.pose?.bones?.master?.[1] ?? 0; return (masterY + (container.rotation.y * 180) / Math.PI) * DEG; }; // --------------------------------------------------------------- backdrop const backdrop = createBackdrop({ stage, resolveUrl: (name) => assetUrl(name, 'background'), onNeedsRender: () => { app.needsRender = true; }, }); /** The bitmap the exports paint behind the model (an Image or the camera video). */ let backdropImage = null; /** * Story-panel previews (keyed by panel id) and the counter that hands out * those ids. * * WHY the previews live outside the state: a thumbnail is an image, and the * state is exactly what a shared link carries - a data URL per panel would * push the link past what a URL can hold. They are keyed by id and rebuilt as * panels are added, so a restored link simply shows panels without previews. * (Declared up here because `buildPanel` runs - and syncs - before the panel * functions below are reached.) */ const storyThumbs = new Map(); let storySeq = 0; // ------------------------------------------------------- caption overlay // The bubble is drawn into its own 2D canvas laid over the WebGL one, with the // very same routine the PNG export uses - so the preview cannot lie. captionCanvas = document.createElement('canvas'); captionCanvas.id = 'caption-layer'; captionCanvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:2'; stage.append(captionCanvas); /** opentype font for outline export and text layout (lazily fetched). */ let captionFont = null; let captionFontPromise = null; /** Where each bubble was last drawn, in CSS pixels (in state order). */ const CAPTION_KEYS = ['caption', 'caption2']; const captionBoxes = new Map(); const captionHandles = new Map(); /** Set while a bubble is being dragged. */ let captionDrag = null; // The bubbles are draggable, but the caption canvas has to stay // `pointer-events: none`: it covers the whole viewport, and the orbit controls // and the bone picking live underneath it. So each bubble gets a separate // transparent drag box, parked exactly over it by `positionCaptionHandles`. for (const key of CAPTION_KEYS) { const handle = document.createElement('div'); handle.id = `caption-handle-${key}`; handle.style.cssText = 'position:absolute;display:none;cursor:move;' + 'touch-action:none;pointer-events:auto;z-index:3'; handle.addEventListener('pointerdown', (event) => startCaptionDrag(event, key)); handle.addEventListener('pointermove', moveCaptionDrag); handle.addEventListener('pointerup', endCaptionDrag); handle.addEventListener('pointercancel', endCaptionDrag); handle.dataset.captionHandle = '1'; stage.append(handle); captionHandles.set(key, handle); } /** Park each drag target over its bubble (or hide it when it is not shown). */ function positionCaptionHandles() { for (const key of CAPTION_KEYS) { const handle = captionHandles.get(key); const box = captionBoxes.get(key); if (!handle) continue; if (!box) { handle.style.display = 'none'; continue; } // A few pixels of slack, so the rounded corners are still easy to grab. handle.style.display = 'block'; handle.style.left = `${box.x - 4}px`; handle.style.top = `${box.y - 4}px`; handle.style.width = `${box.w + 8}px`; handle.style.height = `${box.h + 8}px`; } } function startCaptionDrag(event, key) { const caption = state[key]; const box = captionBoxes.get(key); if (caption?.enabled !== true || !box) return; // Touching a bubble also selects it in the panel, so the sliders edit the one // you just grabbed. app.panel?.selectCaption?.(key); event.preventDefault(); event.stopPropagation(); // Not all pointers can be captured (and a synthetic one cannot), so a // failure here must not stop the drag from starting. try { captionHandles.get(key)?.setPointerCapture(event.pointerId); } catch { /* capture is a nicety, not a requirement */ } captionDrag = { key, pointerId: event.pointerId, fromX: event.clientX, fromY: event.clientY, boxX: box.x, boxY: box.y, }; } function moveCaptionDrag(event) { if (!captionDrag || event.pointerId !== captionDrag.pointerId) return; const caption = state[captionDrag.key]; if (!caption) return; event.preventDefault(); const width = stage.clientWidth || 1280; const height = stage.clientHeight || 800; caption.x = (captionDrag.boxX + (event.clientX - captionDrag.fromX)) / width; caption.y = (captionDrag.boxY + (event.clientY - captionDrag.fromY)) / height; // Store back what the layout will actually use, so pushing past an edge does // not pile up an out-of-range value that has to be undone before the bubble // moves again. `refresh` redraws the bubble and records one undo step (the // history coalesces a run of the same label). const layout = layoutCaption(caption, { width, height, scale: 1, font: captionFont }); if (width > 0) caption.x = layout.box.x / width; if (height > 0) caption.y = layout.box.y / height; refresh('caption'); } function endCaptionDrag(event) { if (!captionDrag || (event && event.pointerId !== captionDrag.pointerId)) return; captionDrag = null; // The 横位置 / 縦位置 sliders catch up when the drag ends, not every pixel. app.actions.syncPanel?.(); } async function ensureCaptionFont() { if (captionFontPromise) return captionFontPromise; captionFontPromise = (async () => { try { const face = new FontFace('M PLUS Rounded 1c', `url(${assetUrl('bluebey-caption.woff2')})`); document.fonts.add(await face.load()); } catch (error) { console.warn('[bluebey] caption font face failed', error); } try { captionFont = await loadFont(assetUrl('bluebey-caption.ttf')); } catch (error) { console.warn('[bluebey] caption outlines unavailable', error); } redrawCaption(); return captionFont; })(); return captionFontPromise; } function redrawCaption() { if (!captionCanvas) return; const width = stage.clientWidth || 1280; const height = stage.clientHeight || 800; const ratio = Math.min(2, window.devicePixelRatio || 1); const wantW = Math.round(width * ratio); const wantH = Math.round(height * ratio); if (captionCanvas.width !== wantW || captionCanvas.height !== wantH) { captionCanvas.width = wantW; captionCanvas.height = wantH; } const ctx = captionCanvas.getContext('2d'); ctx.setTransform(ratio, 0, 0, ratio, 0, 0); ctx.clearRect(0, 0, width, height); // 擬音はセリフより背面に置く: the bubbles stay readable on top of them. const gionItems = state.gion?.items ?? []; for (const item of gionItems) ensureGionSheet(item.sheet); drawGion(ctx, gionItems, gionImages, { width, height, scale: 1 }); for (const key of CAPTION_KEYS) { const caption = state[key]; captionBoxes.delete(key); if (!caption?.enabled) continue; // The vendored font is only fetched once a bubble is actually used; when it // arrives `ensureCaptionFont` redraws this canvas. if (!captionFontPromise) void ensureCaptionFont(); captionBoxes.set(key, drawCaption(ctx, caption, { width, height, scale: 1, font: captionFont })); } positionCaptionHandles(); ensureGionHandles(gionItems); positionGionHandles(gionItems); } // ----------------------------------------------------------------- 擬音 // A stamp is a crop of a sheet, drawn on the caption canvas *under* the bubbles. // The canvas is `pointer-events: none`, so - exactly like a caption - each stamp // gets its own transparent drag box, parked over it by `positionGionHandles`. /** Load a sheet once, on first use, and redraw when it arrives. */ function ensureGionSheet(name) { if (gionImages.has(name)) return Promise.resolve(gionImages.get(name)); if (gionLoading.has(name)) return gionLoading.get(name); const image = new Image(); const promise = new Promise((resolve) => { image.onload = () => resolve(image); image.onerror = () => resolve(null); }).then((loaded) => { gionLoading.delete(name); if (loaded) { gionImages.set(name, loaded); redrawCaption(); } return loaded; }); gionLoading.set(name, promise); image.src = gionSheetUrl(name); return promise; } /** Create one drag target per stamp, and drop the ones whose stamp is gone. */ function ensureGionHandles(items) { const live = new Set(); for (const item of items) { live.add(item.id); if (gionHandles.has(item.id)) continue; const handle = document.createElement('div'); handle.className = 'gion-handle'; handle.addEventListener('pointerdown', (event) => startGionDrag(event, item.id)); handle.addEventListener('pointermove', moveGionDrag); handle.addEventListener('pointerup', endGionDrag); handle.addEventListener('pointercancel', endGionDrag); handle.dataset.gionHandle = '1'; stage.append(handle); gionHandles.set(item.id, handle); } for (const [id, handle] of gionHandles) { if (live.has(id)) continue; handle.remove(); gionHandles.delete(id); } } /** Park each drag target over its stamp (or hide it while the sheet loads). */ function positionGionHandles(items) { const width = stage.clientWidth || 1280; const height = stage.clientHeight || 800; for (const item of items) { const handle = gionHandles.get(item.id); if (!handle) continue; const image = gionImages.get(item.sheet); if (!imageReady(image)) { handle.style.display = 'none'; continue; } const rect = stampRect(item, { width, height, scale: 1 }, imageAspect(image)); handle.style.display = 'block'; handle.style.left = `${rect.x}px`; handle.style.top = `${rect.y}px`; handle.style.width = `${rect.w}px`; handle.style.height = `${rect.h}px`; // Match the drawn stamp, which is rotated about its centre. handle.style.transformOrigin = 'center'; handle.style.transform = item.rot ? `rotate(${item.rot}deg)` : 'none'; handle.classList.toggle('selected', item.id === app.gionSelected); } } function startGionDrag(event, id) { const item = (state.gion?.items ?? []).find((entry) => entry.id === id); if (!item) return; // Touching a stamp also selects it in the panel, so the sliders edit the one // you just grabbed. app.panel?.selectGion?.(id); event.preventDefault(); event.stopPropagation(); try { gionHandles.get(id)?.setPointerCapture(event.pointerId); } catch { /* capture is a nicety, not a requirement */ } gionDrag = { id, pointerId: event.pointerId, fromX: event.clientX, fromY: event.clientY, x: item.x ?? 0.5, y: item.y ?? 0.5, }; } function moveGionDrag(event) { if (!gionDrag || event.pointerId !== gionDrag.pointerId) return; const item = (state.gion?.items ?? []).find((entry) => entry.id === gionDrag.id); if (!item) return; event.preventDefault(); const width = stage.clientWidth || 1280; const height = stage.clientHeight || 800; item.x = clamp01(gionDrag.x + (event.clientX - gionDrag.fromX) / width); item.y = clamp01(gionDrag.y + (event.clientY - gionDrag.fromY) / height); refresh('gion'); } function endGionDrag(event) { if (!gionDrag || (event && event.pointerId !== gionDrag.pointerId)) return; gionDrag = null; app.actions.syncPanel?.(); } /** Add a stamp from a picker crop, centred, and hand it to the panel. */ function addGionStamp({ sheet, sx, sy, sw, sh }) { gionSeq += 1; const item = { id: `g${gionSeq}`, sheet, sx, sy, sw, sh, x: 0.5, y: 0.5, w: DEFAULT_STAMP_WIDTH, rot: 0, flip: false, }; state.gion = state.gion ?? { items: [] }; state.gion.items = [...(state.gion.items ?? []), item]; app.gionSelected = item.id; void ensureGionSheet(sheet); applyState({}, { scope: 'gion', sync: true }); toast('擬音を追加しました'); } // ------------------------------------------------------------------ props let propSignature = ''; function applyProps() { const items = state.props?.items ?? []; const signature = JSON.stringify([items, state.render.colors]); if (signature === propSignature) return; propSignature = signature; for (const child of [...propRoot.children]) { propRoot.remove(child); disposeProp(child); } for (const item of items) { const def = PROP_DEFAULTS[item.kind] ?? { x: 0, y: 0, z: 0, rotY: 0, scale: 1 }; let group; try { group = buildProp(item.kind, { colors: state.render.colors, scale: item.scale ?? def.scale ?? 1 }); } catch (error) { console.warn('[bluebey] unknown prop', item.kind, error); continue; } group.position.set(item.x ?? def.x ?? 0, item.y ?? def.y ?? 0, item.z ?? def.z ?? 0); group.rotation.y = ((item.rotY ?? def.rotY ?? 0) * DEG); propRoot.add(group); // 看板 only: paint the saved text onto the freshly built face (no-op for // every other prop, which has no writing surface). applyPropText(group, item.text); } app.needsRender = true; } // ------------------------------------------------------------ prop dragging // A prop can be dragged with the mouse. The pointer picks one, then moves it in // the plane that faces the camera, so it follows the cursor at whatever depth // it already sits at. (The ground plane is the other obvious choice, but the // camera sits almost level with the floor, so the floor is nearly edge-on and // a prop dragged across it would fly off.) // // The listener is on `stage` in the CAPTURE phase: that runs before the canvas // and `view`'s container, so stopping the event there keeps the orbit controls // and the bone picking out of a prop drag. const propRaycaster = new THREE.Raycaster(); const propPointer = new THREE.Vector2(); const propPlane = new THREE.Plane(); const propPoint = new THREE.Vector3(); const propNormal = new THREE.Vector3(); 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, key: 'wall', offset: new THREE.Vector3() }; /** The pointer in normalised device coordinates, i.e. what a raycaster wants. */ function pointerNdc(event) { const rect = canvas.getBoundingClientRect(); return propPointer.set( ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1, -((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 + 1, ); } /** The prop under the pointer as `{ index, group, point }`, or null. */ function propAt(event) { if (!propRoot.children.length) return null; propRaycaster.setFromCamera(pointerNdc(event), view.camera); const hits = propRaycaster.intersectObjects(propRoot.children, true); if (!hits.length) return null; // The meshes hang off the group `propRoot` holds, so walk up to that group. let node = hits[0].object; while (node.parent && node.parent !== propRoot) node = node.parent; const items = state.props?.items ?? []; const index = propRoot.children.indexOf(node); if (index < 0 || index >= items.length) return null; return { index, group: node, point: hits[0].point.clone() }; } /** * The point on the wall's guide under the pointer, or null. * * Only while the wall is on AND its guide is switched on: the guide is both * what there is to grab and the switch that says "I am placing the wall now", * so with it off a huge invisible plane cannot swallow every orbit drag. */ function wallAt(event) { propRaycaster.setFromCamera(pointerNdc(event), view.camera); 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) { if (event.button !== 0) return; if (event.target?.dataset?.captionHandle) return; // a bubble drags itself if (rig?.controls?.dragging) return; // the rotation gizmo is in charge const hit = propAt(event); if (hit) { // Claim the pointer, so nothing underneath sees it. event.stopPropagation(); event.preventDefault(); view.camera.getWorldDirection(propNormal); propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), hit.point); propDrag = { pointerId: event.pointerId, index: hit.index, group: hit.group, // Grabbing a corner rather than the middle must not make it jump. offset: hit.group.position.clone().sub(hit.point), }; stage.style.cursor = 'grabbing'; app.needsRender = true; return; } const onWall = wallAt(event); if (!onWall) { // Nothing to grab. Remember the press, and settle on pointer-up whether it // was a tap (set the 見る先 target) or the start of an orbit. if (state.lookAt?.enabled === true && !rig?.controls?.dragging) { lookTap = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; } return; } // A prop wins if it is under the pointer; otherwise the wall takes it. event.stopPropagation(); event.preventDefault(); view.camera.getWorldDirection(propNormal); propPlane.setFromNormalAndCoplanarPoint(propNormal.negate(), onWall.point); const wall = state.render[onWall.key] ?? (state.render[onWall.key] = defaultState().render[onWall.key]); const offset = characterOffset(); const yaw = wallYaw(); const cos = Math.cos(yaw); const sin = Math.sin(yaw); const px = onWall.point.x - offset.x; const pz = onWall.point.z - offset.z; wallDrag.pointerId = event.pointerId; 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) - (cos * px - sin * pz), (wall.y ?? 0) - (onWall.point.y - offset.y), (wall.z ?? 0) - (sin * px + cos * pz), ); stage.style.cursor = 'grabbing'; app.needsRender = true; } function onPropPointerMove(event) { if (lookTap && event.pointerId === lookTap.pointerId && Math.hypot(event.clientX - lookTap.x, event.clientY - lookTap.y) > LOOK_TAP_SLOP) { lookTap = null; // that was an orbit drag, not a tap } if (wallDrag.pointerId != null && event.pointerId === wallDrag.pointerId) { event.stopPropagation(); propRaycaster.setFromCamera(pointerNdc(event), view.camera); if (!propRaycaster.ray.intersectPlane(propPlane, propPoint)) return; const offset = characterOffset(); const yaw = wallYaw(); const cos = Math.cos(yaw); const sin = Math.sin(yaw); const px = propPoint.x - offset.x; const pz = propPoint.z - offset.z; 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(cos * px - sin * pz + wallDrag.offset.x, -4, 4)); wall.y = round2(clampNumber(propPoint.y - offset.y + wallDrag.offset.y, -2, 4)); wall.z = round2(clampNumber(sin * px + cos * pz + wallDrag.offset.z, -4, 4)); applyWalls(); app.needsRender = true; return; } if (!propDrag || event.pointerId !== propDrag.pointerId) return; event.stopPropagation(); propRaycaster.setFromCamera(pointerNdc(event), view.camera); if (!propRaycaster.ray.intersectPlane(propPlane, propPoint)) return; const next = propPoint.add(propDrag.offset); const item = state.props?.items?.[propDrag.index]; if (!item) return; // Clamp to the ranges the panel's sliders offer, so a drag can never put a // prop somewhere the numbers cannot describe (and never through the floor). item.x = round2(clampNumber(next.x, -4, 4)); item.y = round2(clampNumber(next.y, 0, 2)); item.z = round2(clampNumber(next.z, -4, 4)); propDrag.group.position.set(item.x, item.y, item.z); // Keep `applyProps`' signature in step with what is on screen, so a refresh // during the drag does not tear the group down and rebuild it from the state // (which would re-create every geometry on every pointer move). propSignature = JSON.stringify([state.props?.items ?? [], state.render.colors]); app.needsRender = true; } function onPropPointerUp(event) { if (lookTap && (!event || event.pointerId === lookTap.pointerId)) { lookTap = null; if (event) applyLookTap(event); return; } if (wallDrag.pointerId != null && (!event || event.pointerId === wallDrag.pointerId)) { wallDrag.pointerId = null; stage.style.cursor = ''; history.push(state, '見えない壁'); app.panel?.sync(); return; } if (!propDrag || (event && event.pointerId !== propDrag.pointerId)) return; propDrag = null; stage.style.cursor = ''; // One undo step for the whole drag, and the panel catches up with the value. history.push(state, '小物'); app.panel?.sync(); } // A tap (a press that does not turn into a drag) in the viewport sets the // 見る先 target, when that mode is on. Cancelled as soon as the pointer moves, // so an orbit drag never moves the target by accident. let lookTap = null; const LOOK_TAP_SLOP = 5; stage.addEventListener('pointerdown', onPropPointerDown, { capture: true }); window.addEventListener('pointermove', onPropPointerMove); window.addEventListener('pointerup', onPropPointerUp); window.addEventListener('pointercancel', onPropPointerUp); // ---------------------------------------------------------------- look-at const lookScratch = new THREE.Vector3(); const headScratch = new THREE.Vector3(); const lookPlane = new THREE.Plane(); const floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); const lookPlaneNormal = new THREE.Vector3(); const lookPivot = new THREE.Vector3(); const lookHit = new THREE.Vector3(); const lookFloor = new THREE.Vector3(); /** Where the eyes should aim this frame, from `state.lookAt` (a world point). */ let aimOffset = { x: 0, y: 0 }; /** * The world point under a viewport tap, for the 見る先 target. * * The tap is projected onto the first thing it can sensibly mean: a vertical * plane through the character that faces the camera (so a tap on the body or * the backdrop keeps the character's own depth), or the floor, for a tap that * lands on the ground in front. The nearer of the two wins. */ function lookAtPointFrom(event) { propRaycaster.setFromCamera(pointerNdc(event), view.camera); const ray = propRaycaster.ray; const headY = container.position.y + model.size.y * 0.72; lookPlaneNormal.set(ray.direction.x, 0, ray.direction.z); if (lookPlaneNormal.lengthSq() < 1e-8) lookPlaneNormal.set(0, 0, 1); lookPlane.setFromNormalAndCoplanarPoint( lookPlaneNormal.normalize(), lookPivot.set(container.position.x, headY, container.position.z), ); const onPlane = ray.intersectPlane(lookPlane, lookHit); const onFloor = ray.direction.y < -1e-4 ? ray.intersectPlane(floorPlane, lookFloor) : null; if (!onPlane) return onFloor; if (!onFloor) return onPlane; return onPlane.distanceToSquared(ray.origin) <= onFloor.distanceToSquared(ray.origin) ? onPlane : onFloor; } /** A tap in the viewport: aim the eyes at whatever was under the pointer. */ function applyLookTap(event) { if (state.lookAt?.enabled !== true) return; const point = lookAtPointFrom(event); if (!point) return; const cfg = state.lookAt; // The same ranges the panel's sliders offer, so a tap and a number describe // the same place. cfg.x = round2(clampNumber(point.x, -6, 6)); cfg.y = round2(clampNumber(point.y, 0, 5)); cfg.z = round2(clampNumber(point.z, -6, 6)); history.push(state, '見る先'); refresh('lookAt'); app.panel?.sync(); } function applyLookAt() { const cfg = state.lookAt; if (!cfg?.enabled) { // Switching the mode off leaves the character where it was: the body turn // and the gaze are remembered, not re-derived, so nothing snaps back. container.rotation.y = (cfg?.bodyYawDeg ?? 0) * DEG; const frozen = cfg?.freeze; return frozen ? { x: clamp(frozen.x ?? 0, -1, 1), y: clamp(frozen.y ?? 0, -1, 1) } : { x: 0, y: 0 }; } container.updateMatrixWorld(true); lookScratch.set(cfg.x ?? 0, cfg.y ?? 0, cfg.z ?? 0); mirrorGroup.worldToLocal(lookScratch); headScratch.set(0, model.size.y * 0.72, 0); lookScratch.sub(headScratch); const flat = Math.hypot(lookScratch.x, lookScratch.z) || 1e-6; const yaw = Math.atan2(lookScratch.x, lookScratch.z); const pitch = Math.atan2(lookScratch.y, flat); const amount = clamp(cfg.amount ?? 1, 0, 1); const aim = { x: clamp((yaw / MAX_LOOK_YAW) * amount, -1, 1), y: clamp((pitch / MAX_LOOK_PITCH) * amount, -1, 1), }; // Turning the whole body is what a person does to look behind themselves; a // half-strength turn keeps the feet planted while the body leans round. The // turn is *stored*, so switching 体も向ける off stops updating it and the body // stays where it is instead of snapping back to the front. if (cfg.turnBody) { cfg.bodyYawDeg = round2(clamp(yaw, -MAX_LOOK_YAW * 1.6, MAX_LOOK_YAW * 1.6) * amount * 0.7 / DEG); } container.rotation.y = (cfg.bodyYawDeg ?? 0) * DEG; // Keep the gaze too, for the moment the mode is switched off. cfg.freeze = aim; return aim; } // -------------------------------------------- history, 口パク, gacha const history = new History({ limit: 120 }); /** How far the mouth is open right now because of the 口パク animation. */ let flapMouth = 0; const mouthFlap = createMouthFlap({ onLevel: (level) => { flapMouth = levelToMouth(level, state.mouthFlap?.mouthGain ?? 1); app.needsRender = true; }, onEnd: () => { flapMouth = 0; app.needsRender = true; }, }); let rig = null; // The gizmo always belongs to whichever camera is currently active. view.onCameraChange = (camera, controls) => { if (!rig) return; rig.controls.camera = camera; rig.orbit = controls; }; rig = new Rig({ bones: model.bones, scene, camera: view.camera, domElement: canvas, orbit: view.controls, pickTargets: () => [model.parts.eyeMesh, model.parts.mouthMesh, ...model.parts.body], onChange: () => { capturePose(); app.panel?.onRigChanged?.(); app.needsRender = true; }, }); // The ground, the contact shadow and the gizmo are scenery, not character, and // the two face plates are the *front half* of the body: their open edge would be // picked up as a silhouette and draw a line across the face, while contributing // nothing, since they sit exactly on the body. // // The outline hulls must stay out too, and that one is easy to miss: they are // extra copies of every mesh sitting in the scene graph, and this pass swaps the // material of every visible mesh. A hull is an expanded *back-face* shell, so // handing it a plain front-side label material puts an expanded copy of the // whole character in front of itself - it then paints its own label over // everything, and every leaf edge against the body is inked as if the body were // empty paper. That is exactly the faint dotted line along the leaves' bases. function outlineExclusion() { return [ ground, look.contact, ...clip.guides, rig.helper, model.parts.eyeMesh, model.parts.mouthMesh, ...snotMeshes, ...styles.outlineMeshes, ]; } outline.exclude(outlineExclusion()); // The wall is *not* excluded: it is handed to the pass as an occluder, so the // 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.occluders); /** * The parts the screen-space pass draws, with the label each one writes. * * The leaves are the hard case: a hull cannot outline a shell that thin. They * are drawn with the LEAF label, which means the pass inks them where they meet * the paper and where they fold against each other, but *not* where they run * into the body - a line there reads as the leaf sinking into the body, and the * original artwork has none (the leaf simply passes behind the body). * * The nose joins them in the line-art styles, with the NOSE label, so its ring * against the body *is* drawn: it is the only thing that shows the nose in a * line drawing. In the shaded styles it is left out entirely, and its hull then * keeps only the part that pokes out of the silhouette - which is what the * original artwork does (the nose reads by its own colour there). * * @param {string} style * @returns {Array<[import('three').Object3D, number]>} */ function screenParts(style) { const parts = (model.parts.leaves ?? []).map((mesh) => [mesh, LEAF_LABEL]); if (isLineStyle(style) && model.parts.noseMesh) parts.push([model.parts.noseMesh, NOSE_LABEL]); return parts; } /** * The label every part writes (see src/outline.js). * * The leaves and the nose are the outlined ones. The body's label is the one * real choice here, and it is exposed as `render.leafBodyLine`: * * - `BEHIND` (the default) makes the body count as paper, so each leaf is * outlined where it emerges from the body. In a line drawing the two are * paper on paper, so without this the leaves and the body merge into one * white shape with no way to tell them apart - which is the greater evil. * - `SOLID` makes the body block that line, so a leaf simply passes behind it. * Cleaner where it works, but the leaves lose their outline there. * * Everything else - the feet, the hands, the props - is *merely behind*, so it * stays BEHIND either way: it still hides what is behind it, but a leaf lying * across it keeps its outline. */ function outlineLabels(parts) { const labels = new Map(parts); const bodyLabel = state.render.leafBodyLine === false ? SOLID_LABEL : BEHIND_LABEL; for (const mesh of model.parts.body ?? []) { if (labels.has(mesh)) continue; labels.set(mesh, model.parts.kinds?.get(mesh) === 'blb' ? bodyLabel : BEHIND_LABEL); } return [...labels]; } /** Point both outline systems at the right parts for `method`. */ function applyOutlineMethod(style, method = state.render.outlineMethod) { const hybrid = method === 'screen'; const parts = hybrid ? screenParts(style) : []; styles.setHullHidden(parts.map(([mesh]) => mesh)); outline.only(parts.length ? outlineLabels(parts) : null); } /** * Run `fn` with the outline pass set up for another style, then put the * on-screen configuration back. Both the hull/screen split and which parts the * screen pass may see are shared by the live view and every offscreen render, * so a pass that draws another style has to say so - the SVG and the "lines * only" PNG are ink through and through, so they always want the line-art * arrangement (a hull cannot outline the leaves at all). */ async function withOutlineFor({ style, method = 'screen' }, fn) { applyOutlineMethod(style, method); try { return await fn(); } finally { applyOutlineMethod(state.render.style); } } Object.assign(app, { renderer, scene, camera: view.camera, view, styles, face, rig, animator, key, ambient, ground, environment, halfHeight, outline, clip, look, backdrop, history, mouthFlap, propRoot, mirrorGroup, container, get captionFont() { return captionFont; }, get backdropImage() { return backdropImage; }, }); const recorder = createRecorder(canvas); // -------------------------------------------------------------- the panel const panel = buildPanel(app, document.getElementById('panel')); app.panel = panel; Object.assign(app.actions, { capturePose, applyState, refresh, patch: (scope, value) => applyState({ [scope]: value }, { scope }), syncPanel: () => app.panel?.sync(), imageCanvas: (options) => captureImage(options), savePNG: (options = {}) => savePNG(options), copyPNG: () => copyPNG(), faceMapCanvas: (kind) => faceMapCanvas(kind), saveFaceMap: (kind) => saveFaceMap(kind), addGionStamp: (item) => addGionStamp(item), saveSettings: () => exporter.downloadText(JSON.stringify(state, null, 2), `bluebey-settings-${exporter.timestamp()}.json`), loadSettings: (text) => { const data = JSON.parse(text); applyState(data, { full: true, sync: true }); toast('設定を読み込みました'); }, toggleRecording: () => toggleRecording(recorder), resetPose: () => { rig.reset(); state.pose = { bones: {}, root: [0, 0, 0] }; applyState({}, { scope: 'pose', sync: true }); toast('ポーズをリセットしました'); }, setCameraPreset: (id) => setCameraPreset(id), undo: () => restoreFromHistory('undo'), redo: () => restoreFromHistory('redo'), applyTheme: (id) => applyTheme(id), rollGacha: (seed) => rollGacha(seed), copyShareLink: () => copyShareLink(), shareImage: (network) => shareImage(network), toggleMouthFlap: () => toggleMouthFlap(), storyThumbs: () => storyThumbs, toggleCamera: (on) => toggleCamera(on), addProp: (kind) => addProp(kind), addStoryPanel: () => addStoryPanel(), saveStory: (options) => saveStory(options), loadFromHash: () => loadFromHash(), toggleAnimation: () => { state.anim.mode = (state.anim.mode ?? 'off') === 'off' ? 'idle' : 'off'; app.animator.reset(); panel.sync(); app.needsRender = true; }, }); installKeys(); installTopbar(); window.addEventListener('focus', () => { app.needsRender = true; }); document.addEventListener('visibilitychange', () => { if (!document.hidden) app.needsRender = true; }); { const buffer = renderer.getDrawingBufferSize(new THREE.Vector2()); console.log( '[bluebey] ready' + ` canvas=${canvas.clientWidth}x${canvas.clientHeight}` + ` buffer=${Math.round(buffer.x)}x${Math.round(buffer.y)}` + ` quality=${quality}` + ` dpr=${window.devicePixelRatio}` + ` model=${model.size.toArray().map((v) => v.toFixed(2)).join(',')}` + ` camera=${view.camera.position.toArray().map((v) => v.toFixed(2)).join(',')}`, ); } window.addEventListener('resize', () => resizeViewport()); applyState({}, {}); await loadFromHash(); history.reset(state); rig.select('master', { silent: true }); panel.sync(); let last = performance.now(); let faceSignature = ''; let slowFrames = 0; let framesSinceFps = 0; let fpsAt = performance.now(); const warmupUntil = performance.now() + 2500; let snotSignature = null; const loop = (now) => { const rawDelta = now - last; const dt = Math.min(0.05, rawDelta / 1000); last = now; animator.update(dt); const posed = animator.pose(state.pose); if (!rig.controls.dragging) rig.applyPose(posed); container.position.set(posed.root[0] ?? 0, posed.root[1] ?? 0, posed.root[2] ?? 0); updateKeyLight(); // The contact blob rides under the character, so it never lingers at the origin. look.contact.position.x = container.position.x; look.contact.position.z = container.position.z; if (snotBubble) { const snot = state.face.eyes.snot; const sig = `${snot.enabled}|${snot.size}|${snot.offsetX}|${snot.offsetY}|${snot.offsetZ}|${snot.color}`; if (sig !== snotSignature) { snotSignature = sig; applySnotBubble(snotBubble, snot); app.needsRender = true; } // The sleeping breath swells the bubble; `snotScale` is 1 otherwise. if (snotBubble.visible) { const scale = (snotBubble.userData.baseScale ?? 1) * (animator.snotScale ?? 1); if (snotBubble.scale.x !== scale) { snotBubble.scale.setScalar(scale); app.needsRender = true; } } } const params = buildFaceParams(); const signature = JSON.stringify(params); if (signature !== faceSignature) { faceSignature = signature; face.setParams(params, faceMode()); app.needsRender = true; } if (face.flush(now, 26)) app.needsRender = true; view.controls.update(dt); const animating = (state.anim.mode ?? 'off') !== 'off' || state.anim.lookAround || animator.blinkPhase >= 0 || recorder?.recorder?.state === 'recording'; // Always paint for the first couple of seconds, so a missed "needs render" // can never leave an empty window staring back at the user. if (app.needsRender || animating || view.controlsChanged || now < warmupUntil) { app.needsRender = false; // The screen-space outline needs one extra pass *before* the scene, so it // wraps the normal render instead of following it. `outline.render` paints // the scene itself (ink and all), so there must be no second render after // it - that would clear the canvas and wipe the ink straight back off. const wantsScreen = state.render.outlineMethod === 'screen' && state.render.outline !== false; outline.render(() => renderer.render(scene, view.camera), { enabled: wantsScreen, camera: view.camera, color: state.render.outlineColor, // The label buffer is `quality`x the CSS size, so the radius has to carry // that factor to keep the leaf/nose line a constant width in CSS pixels - // otherwise it halves on a HiDPI display and jumps when the app lowers // the quality on slow frames. radius: outlineRadiusFor(view, state, quality), }); view.controlsChanged = false; framesSinceFps += 1; // Hardware that cannot keep up gets a smaller drawing buffer instead of a // slideshow (a very large buffer can look like "nothing ever appears"). if (rawDelta > 90) slowFrames += 1; else slowFrames = 0; if (slowFrames >= 8 && quality > QUALITY_FLOOR + 0.01 && autoReductions < 3) { autoReductions += 1; quality = Math.max(QUALITY_FLOOR, Math.round((quality - 0.25) * 100) / 100); renderer.setPixelRatio(quality); slowFrames = 0; console.warn('[bluebey] frames are slow, lowering the drawing quality to', quality); } } if (now - fpsAt > 1000) { const fps = Math.round((framesSinceFps * 1000) / (now - fpsAt)); framesSinceFps = 0; fpsAt = now; const buffer = renderer.getDrawingBufferSize(new THREE.Vector2()); panel.setStats?.( `描画 ${Math.round(buffer.x)}×${Math.round(buffer.y)}(画質 ${quality.toFixed(2)}×)/約 ${fps}fps` + (autoReductions > 0 ? ' ※重いので自動で軽くしました' : ''), ); } requestAnimationFrame(loop); }; requestAnimationFrame(loop); hideLoading(); app.ready = true; window.__bluebeyReady = true; window.__bluebey = { app, state, set: (patch) => applyState(patch), face: (patch) => applyState({ face: patch }), pose: (patch) => applyState({ pose: patch }), view: (patch) => applyState({ view: patch }), render: (patch) => applyState({ render: patch }), selectBone: (name) => rig.select(name), preset: (id) => app.actions.applyFacePreset?.(id), posePreset: (id) => app.actions.applyPosePreset?.(id), png: (options) => captureImage(options), }; /* ------------------------------------------------------------- internals */ /** The camera the outline pass should use (kept in step with the view rig). */ function outlineCamera() { return view.camera; } /** The gizmo/frame loop re-reads the bones, so store them back into state. */ function capturePose() { const pose = rig.getPose(); state.pose.bones = pose.bones; } function buildFaceParams() { const eyes = state.face.eyes; const openScale = animator.eyeOpen; const drift = animator.look; const eyeFor = (key) => { const own = eyes[key]; return { open: clamp01(own.open * openScale), lookX: clamp(own.lookX + drift.x + aimOffset.x, -1, 1), lookY: clamp(own.lookY + drift.y + aimOffset.y, -1, 1), closed: own.closed, closedLines: own.closedLines, irisShape: own.irisShape, threeFlip: own.threeFlip === true, // The tears are per eye, so one eye can cry on its own. tear: own.tear ?? 0, tearY: own.tearY ?? 0, // Where this eye sits, and where its teardrop hangs, can be nudged one // side at a time (an asymmetric face). eyeX: own.eyeX ?? 0, tearX: own.tearX ?? 0, tearTilt: own.tearTilt ?? 0, }; }; // Lip-sync and a look-at target both drive the mouth and the eyes without // being part of the saved expression. const mouth = { ...state.face.mouth }; if (flapMouth > 0.001) mouth.open = Math.max(mouth.open ?? 0, flapMouth); // A talking mouth has no tongue sticking out. This follows the whole flap // session rather than the opening: the envelope dips through zero between // syllables, and keying it off the opening made the tongue flicker back into // view on every closed frame. if (mouthFlap.running) mouth.tongue = 0; return { eyes: { // Everything shared by both eyes (colours, iris scale, brows, glasses, // ...) has to travel with the per-eye values, because the face renderer // reads them from here. Spreading `...eyes` is also what makes the face // redraw when one of them (e.g. `glasses`) changes: the loop compares the // JSON of this object against the last one it drew. ...eyes, left: eyeFor('left'), right: eyeFor('right'), }, mouth, }; } function faceMode() { return isLineStyle(state.render.style) ? 'line' : 'paint'; } function applyState(patch, { silent = false, full = false, scope = 'all', sync = false } = {}) { if (full) { const fresh = defaultState(); applyPatch(fresh, patch); for (const key of Object.keys(fresh)) state[key] = fresh[key]; } else { applyPatch(state, patch); } refresh(scope); app.needsRender = true; if (sync && !silent) app.panel?.sync(); } /** Re-apply the parts of the state named by `scope` ('all' by default). */ function refresh(scope = 'all') { app.needsRender = true; // Where the eyes aim depends on where the character is, so it is recomputed // whenever anything that moves could change. if (scope === 'all' || scope === 'view' || scope === 'pose' || scope === 'face' || scope === 'lookAt') { aimOffset = applyLookAt(); } if (scope === 'all' || scope === 'render') { styles.setStyle(state.render.style); styles.setPaper(state.render.paper); styles.setOutlineWidth(state.render.outlineWidth); styles.setOutlineColor(state.render.outlineColor); styles.setOutlineEnabled(state.render.outline); // Which parts each outline method draws, and which hulls stand down for it // (the leaves, plus the nose in a line drawing). See `applyOutlineMethod`. applyOutlineMethod(state.render.style); const line = isLineStyle(state.render.style); // Shadows, reflections, body colours and mirroring are the look module's // job, so that they are applied together and stay consistent. look.apply(state.render, { line }); applyProps(); // 見えない壁: the one setting that changes what is drawn rather than how. applyWalls(); const azimuth = state.render.lightAzimuth * DEG; const elevation = state.render.lightElevation * DEG; const distance = model.size.y * 3; keyOffset.set( Math.cos(elevation) * Math.sin(azimuth) * distance, Math.sin(elevation) * distance, Math.cos(elevation) * Math.cos(azimuth) * distance, ); updateKeyLight(); key.intensity = state.render.lightIntensity; ambient.intensity = state.render.ambient; renderer.toneMappingExposure = state.render.exposure; applyBackground(); renderer.shadowMap.needsUpdate = true; } if (scope === 'all' || scope === 'view') { applyBackdrop(); // The renderer's *clear* depends on the background mode as well: a preset // photo, a loaded picture and the camera all show through a transparent // clear (see `backgroundOf`). Without this, switching the background left // the canvas clearing to the old opaque colour and hid the layer that had // just been set up behind it - which is why the background only appeared // after some other change (adding a prop) re-ran this. applyBackground(); } if (scope === 'all' || scope === 'view' || scope === 'caption' || scope === 'gion') redrawCaption(); // A style change flips the face between paint and ink (`faceMode`), so the // 'render' scope has to rebuild the face too - otherwise the old drawing // stays on the plates until something else (a blink) happens to dirty them. if (scope === 'all' || scope === 'face' || scope === 'render') { face.setSource('eyes', state.face.eyes.source); face.setSource('mouth', state.face.mouth.source); face.setParams(buildFaceParams(), faceMode()); } if (scope === 'all' || scope === 'view') { view.setProjection(state.view.projection); view.apply(state.view, model.size); view.setAutoRotate(state.view.autoRotate, state.view.autoRotateSpeed); } if (scope === 'all' || scope === 'pose') { rig.applyPose(state.pose); const root = state.pose.root ?? [0, 0, 0]; container.position.set(root[0] ?? 0, root[1] ?? 0, root[2] ?? 0); } // Every user-driven change funnels through here, so this is the one place // that has to remember a step for undo. Undo itself sets `suspendHistory`. if (!suspendHistory) history.push(state, SCOPE_LABELS[scope] ?? scope); } /** Paint the backdrop bitmap behind the model, for an export. */ function backdropSource() { if (state.view.background === 'camera') { const video = backdrop.el?.querySelector?.('video'); return video && video.readyState >= 2 ? video : null; } return backdropImage; } /** * Paint the backdrop *behind* the rendered model. * * WHY the scratch canvas and `destination-over`: `ctx` already holds the * character on a transparent field (see `exporter.renderStill`), so drawing the * photo straight onto it with the default `source-over` painted OVER the * character and the export came out as a bare photograph. Building the scene in * a scratch canvas first also lets the dark veil sit on top of the photo, which * it could not do once the photo had been slipped underneath. */ function drawBackdropInto(ctx, width, height, scale) { const source = backdropSource(); if (!source) return false; const style = backdropStyle(state.view); if (!style.visible) return false; const iw = source.naturalWidth || source.videoWidth || 0; const ih = source.naturalHeight || source.videoHeight || 0; if (!iw || !ih) return false; const back = document.createElement('canvas'); back.width = width; back.height = height; const paint = back.getContext('2d'); // The same CSS the live layer uses: blur (bleeding past the edges), the fit, // the mirror and the scale/offset transform. const bleed = style.blur > 0 ? style.blur * 2 : 0; const w = width + bleed * 2; const h = height + bleed * 2; paint.save(); if (style.blur > 0) paint.filter = `blur(${style.blur}px)`; paint.translate(width / 2 + style.offset.x * width, height / 2 + style.offset.y * height); paint.scale(style.scale * (style.mirror ? -1 : 1), style.scale); paint.translate(-width / 2, -height / 2); if (style.backgroundSize === 'cover' || style.backgroundSize === 'contain') { const fit = style.backgroundSize === 'cover' ? Math.max(w / iw, h / ih) : Math.min(w / iw, h / ih); const dw = iw * fit; const dh = ih * fit; paint.drawImage(source, (w - dw) / 2 - bleed, (h - dh) / 2 - bleed, dw, dh); } else if (style.backgroundSize === 'auto') { // CSS repeats the picture at its own size and anchors the grid on the box // centre (`background-position: center`). A canvas pattern instead repeats // at the image's *intrinsic* size, which is `scale`x too small for a // canvas that is `scale`x the CSS box - so the export tiled `scale`x more // densely than the preview. Paint the tiles by hand at `scale`, on a grid // centred on the box centre; the CTM above already carries the offset / // backgroundScale / mirror, so the tiles must not re-apply them. const tw = iw * scale; const th = ih * scale; // Cover only the user-space area the visible canvas needs (widened by the // blur bleed), mapped back through the CTM. The CTM holds no rotation, so // its `a`/`d` scale factors are enough to invert it for the bounds. const matrix = paint.getTransform(); const [left, right] = [ (-bleed - matrix.e) / matrix.a, (width + bleed - matrix.e) / matrix.a, ].sort((a, b) => a - b); const [top, bottom] = [ (-bleed - matrix.f) / matrix.d, (height + bleed - matrix.f) / matrix.d, ].sort((a, b) => a - b); const cx = width / 2; const cy = height / 2; const firstCol = Math.floor((left - cx) / tw); const lastCol = Math.ceil((right - cx) / tw); const firstRow = Math.floor((top - cy) / th); const lastRow = Math.ceil((bottom - cy) / th); for (let row = firstRow; row <= lastRow; row++) { for (let col = firstCol; col <= lastCol; col++) { paint.drawImage(source, cx + col * tw - tw / 2, cy + row * th - th / 2, tw, th); } } } else { paint.drawImage(source, -bleed, -bleed, w, h); } paint.restore(); if (style.darken > 0) { paint.fillStyle = `rgba(0, 0, 0, ${style.darken})`; paint.fillRect(0, 0, width, height); } ctx.save(); ctx.globalCompositeOperation = 'destination-over'; ctx.drawImage(back, 0, 0); ctx.restore(); return true; } /** Load (or clear) the backdrop bitmap that the exports composite. */ function applyBackdrop() { const viewState = state.view; backdrop.apply(viewState); const url = viewState.background === 'preset' ? assetUrl(viewState.backgroundPreset, 'background') : viewState.background === 'image' ? viewState.backgroundImage : viewState.background === 'effect' ? backdrop.effectUrl(viewState.backgroundEffect) : null; if (!url) { if (viewState.background !== 'camera') backdropImage = null; return; } if (backdropImage?.dataset?.url === url) return; const image = new Image(); image.dataset.url = url; image.onload = () => { backdropImage = image; app.needsRender = true; }; image.onerror = () => { backdropImage = null; }; image.src = url; } function applyBackground() { exporter.applyBackground(renderer, backgroundOf(state.view)); } async function captureImage({ scale = 2, transparent = null, lines = null } = {}) { return withHiddenHelpers(async () => { // The face texture is redrawn on a throttle (every 26 ms) while the render // loop runs, so a capture taken straight after a change could still hold the // *previous* expression. That is what made a comic panel occasionally show // its neighbour's face: force the pending redraw through first. face.flush(performance.now(), 0); const wantsTransparent = transparent === true || (transparent == null && state.view.background === 'transparent'); // A transparent export of a line drawing drops the paper as well. The whole // point of transparent line art is to lay the lines over another picture, // and a white silhouette would just hide it - so this renders the `outline` // style, which draws the ink and leaves the body out. A shaded style keeps // its body, because there it is an ordinary cut-out of the character. const wantsLines = lines ?? (wantsTransparent && isLineStyle(state.render.style)); const drawn = wantsLines ? 'outline' : state.render.style; const background = wantsTransparent ? { mode: 'transparent' } : backgroundOf(state.view); const onScreen = styles.style; if (drawn !== onScreen) styles.setStyle(drawn); let result; try { result = await withOutlineFor({ style: drawn, method: wantsLines ? 'screen' : state.render.outlineMethod }, () => exporter.capturePNG({ renderer, scene, camera: view.camera, outline, outlineOptions: { enabled: state.render.outlineMethod === 'screen' && state.render.outline !== false, color: state.render.outlineColor, // The export's label buffer is `scale`x the one on screen (renderStill // sets it to the output size), so its radius grows with it to keep the // line's weight relative to the model the same as in the preview. radius: outlineRadiusFor(view, state, scale), }, width: canvas.clientWidth || window.innerWidth, height: canvas.clientHeight || window.innerHeight, restore: () => { resizeViewport(); applyStateBackgroundAgain(); }, }, { scale, background })); } finally { if (drawn !== onScreen) styles.setStyle(onScreen); } // The backdrop, the 擬音 stamps and the bubbles live in DOM layers behind // and above the WebGL canvas, so an export has to paint them in itself - in // the same order as the screen: backdrop, then stamps, then bubbles. const ctx = result.canvas.getContext('2d'); // The canvas' pixels-per-CSS-pixel: the same factor the 擬音 stamps and the // bubbles are drawn at below. The backdrop's tiled fit needs it too, so its // tiles come out the size the preview's CSS paints them. const base = canvas.clientWidth || 1280; const outputScale = result.width / Math.max(1, base); let changed = false; if (!wantsTransparent && drawBackdropInto(ctx, result.width, result.height, outputScale)) changed = true; const gionItems = state.gion?.items ?? []; if (gionItems.length) { for (const item of gionItems) await ensureGionSheet(item.sheet); drawGion(ctx, gionItems, gionImages, { width: result.width, height: result.height, scale: outputScale, }); changed = true; } const bubbles = CAPTION_KEYS.map((key) => state[key]).filter((caption) => caption?.enabled); if (bubbles.length) { await ensureCaptionFont(); for (const caption of bubbles) { drawCaption(ctx, caption, { width: result.width, height: result.height, scale: outputScale, font: captionFont, }); } changed = true; } if (changed) result.blob = await exporter.canvasToBlob(result.canvas); return result; }); } function applyStateBackgroundAgain() { exporter.applyBackground(renderer, backgroundOf(state.view)); } async function savePNG({ scale = state.render.pngScale ?? 2, transparent = state.render.pngTransparent ? true : undefined, } = {}) { const result = await captureImage({ scale, transparent }); exporter.downloadBlob(result.blob, `bluebey-${exporter.timestamp()}.png`); toast(`PNGを書き出しました(${result.width}×${result.height})`); } /** * The canvas the 下地 export paints on, *without* downloading it. Kept separate * from `saveFaceMap` so the in-page round-trip check can look at exactly the * pixels the button writes. */ function faceMapCanvas(kind) { // A redraw is throttled while the render loop runs (see the loop's // `face.flush(now, 26)`), so force any pending one through first or the map // would show the expression before last. face.flush(performance.now(), 0); return exporter.buildFaceMap(face, kind); } async function saveFaceMap(kind) { const canvas = faceMapCanvas(kind); const blob = await exporter.canvasToBlob(canvas); const label = kind === 'eyes' ? '目' : '口'; exporter.downloadBlob(blob, `bluebey-face-${kind}-${exporter.timestamp()}.png`); toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。` + `この画像に描いて「${label}の画像を読み込む(PNG)」で読み込めます`); } /** Hide the gizmo and helpers so they never end up in an export. */ async function withHiddenHelpers(fn) { const wasVisible = rig.helper.visible; const guidesWereVisible = clip.guides.map((guide) => guide.visible); rig.helper.visible = false; clip.guides.forEach((guide) => { guide.visible = false; }); try { return await fn(); } finally { rig.setGizmoVisible(wasVisible); clip.guides.forEach((guide, index) => { guide.visible = guidesWereVisible[index]; }); app.needsRender = true; } } async function copyPNG() { const result = await captureImage({ scale: 2 }); await exporter.copyCanvasToClipboard(result.canvas); toast('画像をクリップボードにコピーしました'); } /** * Swap in another GLB. * * Kept as the implementation, but no longer wired to anything: the panel's * 「GLBを差し替える」 button and the window-wide drop handler were removed on * 2026-09-27. Putting the feature back is a button plus an action that calls * this (the model returns on reload, so nothing is saved). */ async function replaceModel(file) { if (!file) return; try { const buffer = await exporter.readFileAsArrayBuffer(file); const next = await loadModel(buffer); styles.dispose(); rig.dispose(); container.remove(model.root); scene.remove(container); container.add(next.root); scene.add(container); app.model = next; toast(`${file.name} を読み込みました(このモデルは再読込で元に戻ります)`); app.needsRender = true; } catch (error) { console.error(error); toast('このファイルは読み込めませんでした'); } } let recording = false; async function toggleRecording(session) { if (!session) { toast('この環境では録画できません'); return; } if (!recording) { if (!state.anim.idle) { state.anim.idle = true; panel.sync(); } recording = startRecording(session); toast('録画を開始しました(もう一度押すと停止)'); } else { const blob = await stopRecording(session); recording = false; if (blob) { exporter.downloadBlob(blob, `bluebey-animation-${exporter.timestamp()}.webm`); toast('録画を保存しました(WebM)'); } } app.panel?.setRecording(recording); } function setCameraPreset(id) { const presets = { front: { azimuth: 0, polar: 82 }, threeQuarter: { azimuth: 34, polar: 78 }, side: { azimuth: 90, polar: 84 }, back: { azimuth: 180, polar: 82 }, top: { azimuth: 24, polar: 26 }, }; applyState({ view: presets[id] ?? presets.front }, { scope: 'view', sync: true }); } function restoreFromHistory(kind) { const snapshot = kind === 'undo' ? history.undo() : history.redo(); if (!snapshot) { toast(kind === 'undo' ? 'これ以上戻れません' : 'やり直す操作がありません'); return; } suspendHistory = true; try { applyState(snapshot, { full: true, sync: true }); } finally { suspendHistory = false; } toast(kind === 'undo' ? '1つ戻しました' : 'やり直しました'); } function applyTheme(id) { const theme = THEMES.find((item) => item.id === id); if (!theme) return; state.render.theme = id; state.render.colors = { ...theme.colors }; applyState({}, { scope: 'render', sync: true }); } /** おまかせ: a seeded random expression, so it can be reproduced and shared. */ function rollGacha(seedText) { const seed = seedText || String(Math.floor(Math.random() * 1e9)); const roll = rollAll(seed); suspendHistory = true; try { applyState(roll.patch, { scope: 'all' }); } finally { suspendHistory = false; } history.push(state, 'おまかせ'); app.panel?.sync(); const shown = decodeSeed(seed) ?? String(seed); toast(`おまかせ表情(シード ${shown})`); return shown; } async function copyShareLink() { try { const encoded = await encodeState(state); const link = `${location.origin}${location.pathname}#s=${encoded}`; await navigator.clipboard.writeText(link); toast(`この見た目のリンクをコピーしました(${link.length}文字)`); } catch (error) { console.error(error); toast('リンクを作れませんでした'); } } /** The tag every shared picture carries. */ const SHARE_TAG = '#ぶるべースタジオ'; /** The studio's own address - the landing page itself, not a state-reproducing * `#s=` link. Shared alongside the tag so a viewer can find the studio. */ function studioUrl() { return `${location.origin}${location.pathname}`; } /** * Share the current look as a *picture*, not as a link. * * Neither X nor Facebook's web dialog can attach an image, so the picture goes * through the OS share sheet (`navigator.share` with files) wherever the browser * has one - a single tap on a phone, and it can go to any app. Where there is no * share sheet (most desktops), the PNG is put on the clipboard and the network's * compose window is opened with the tag already typed, so the picture only has to * be pasted. The tag *and* the studio link always travel together: the user asks * for both to be kept when the picture is shared. */ async function shareImage(network) { let shot; try { shot = await captureImage({ scale: 1.5, transparent: false }); } catch (error) { console.error(error); toast('画像を作れませんでした'); return; } const file = new File([shot.blob], `bluebey-${exporter.timestamp()}.png`, { type: 'image/png' }); if (!network && navigator.canShare?.({ files: [file] })) { try { await navigator.share({ files: [file], text: SHARE_TAG, url: studioUrl() }); return; } catch (error) { if (error?.name === 'AbortError') return; console.error(error); } } // No share sheet (or a named network): put the picture on the clipboard and // open that network's compose window with the tag and the studio link filled in. let copied = false; try { await exporter.copyCanvasToClipboard(shot.canvas); copied = true; } catch (error) { console.error(error); } const studio = studioUrl(); const target = network === 'facebook' ? `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(studio)}` : `https://twitter.com/intent/tweet?text=${encodeURIComponent(SHARE_TAG)}&url=${encodeURIComponent(studio)}`; window.open(target, '_blank'); toast(copied ? '画像をコピーしました。開いた画面に貼り付けて投稿してください(Ctrl+V)' : '投稿画面を開きました(この環境では画像を自動で貼り付けできません)'); } function toggleMouthFlap() { if (mouthFlap.running) { mouthFlap.stop(); toast('口パクを止めました'); return; } const seconds = mouthFlap.start(state.mouthFlap?.text ?? '', { rate: state.mouthFlap?.rate ?? 1, }); toast(`口パクを始めました(音は出ません・約${seconds.toFixed(1)}秒)`); } async function toggleCamera(on) { if (!on) { backdrop.stopCamera(); applyState({ view: { background: 'solid' } }, { scope: 'view', sync: true }); return; } const result = await backdrop.startCamera(state.view.cameraFacing ?? 'environment'); if (!result?.ok) { toast(result?.reason ?? 'カメラを使えません'); return; } applyState({ view: { background: 'camera' } }, { scope: 'view', sync: true }); toast('カメラの映像を背景にしました(そのまま写真に撮れます)'); } function addProp(kind) { const def = PROP_DEFAULTS[kind] ?? { x: 1.6, y: 0, z: 0, rotY: 0, scale: 1 }; state.props.items = [...(state.props.items ?? []), { kind, x: def.x ?? 0, y: def.y ?? 0, z: def.z ?? 0, rotY: def.rotY ?? 0, scale: def.scale ?? 1, }]; applyState({}, { scope: 'render', sync: true }); } async function addStoryPanel() { const index = (state.story.panels?.length ?? 0) + 1; // A counter, not the array length: removing a panel and then adding another // used to hand out an id that was already taken, which confused the list. storySeq += 1; const id = `p${storySeq}`; state.story.panels = [...(state.story.panels ?? []), { id, label: `コマ${index}`, pose: JSON.parse(JSON.stringify(state.pose)), face: JSON.parse(JSON.stringify(state.face)), caption: JSON.parse(JSON.stringify(state.caption)), caption2: JSON.parse(JSON.stringify(state.caption2)), // The 擬音 stamps as well, so a panel comes back with everything it showed. gion: JSON.parse(JSON.stringify(state.gion)), // The camera as well as the pose: recalling a panel should put you back // where you were looking when you recorded it. view: JSON.parse(JSON.stringify(state.view)), }]; applyState({}, { scope: 'caption', sync: true }); // Then photograph what was just recorded, so the list shows the shot rather // than only its name. try { const shot = await captureImage({ scale: 0.35, transparent: false }); storyThumbs.set(id, { version: (storyThumbs.get(id)?.version ?? 0) + 1, url: thumbDataUrl(shot.canvas), }); toast(`コマ${index}を追加しました`); } catch (error) { console.warn('[bluebey] story thumbnail failed', error); toast(`コマ${index}を追加しました(プレビューは作れませんでした)`); } app.panel?.sync(); } /** Shrink a capture down to a thumbnail data URL for the panel list. */ function thumbDataUrl(source, width = 168) { const scale = width / Math.max(1, source.width); const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(source.width * scale)); canvas.height = Math.max(1, Math.round(source.height * scale)); const ctx = canvas.getContext('2d'); ctx.imageSmoothingQuality = 'high'; ctx.drawImage(source, 0, 0, canvas.width, canvas.height); return canvas.toDataURL('image/png'); } /** * A number in the corner of a comic panel, for sheets whose reading order is * not obvious. Drawn with the vendored caption font, so the sheet does not * depend on whatever fonts the viewer happens to have. */ function drawPanelNumber(ctx, number, x, y, cellW, cellH) { const radius = Math.max(18, Math.min(64, Math.min(cellW, cellH) * 0.075)); const margin = radius * 0.7; const cx = x + margin + radius; const cy = y + margin + radius; ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255, 255, 255, 0.92)'; ctx.fill(); ctx.lineWidth = Math.max(2, radius * 0.14); ctx.strokeStyle = '#3f2b52'; ctx.stroke(); ctx.fillStyle = '#3f2b52'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `bold ${Math.round(radius * 1.25)}px ${captionFontStack()}`; ctx.fillText(String(number), cx, cy + radius * 0.04); ctx.restore(); } /** * まんが: replay the panels, capture each one, and hand back a sheet of them * plus a zip of the individual frames. */ async function saveStory({ scale = 2, includeZip = true } = {}) { const panels = state.story?.panels ?? []; if (!panels.length) { toast('コマがありません。「今の状態をコマに追加」で作ってください'); return; } const saved = JSON.parse(JSON.stringify(state)); const captures = []; suspendHistory = true; try { for (const panel of panels) { const pose = panel.pose ?? {}; state.pose = { bones: pose.bones ?? {}, root: pose.root ?? [0, 0, 0] }; state.face = JSON.parse(JSON.stringify(saved.face)); applyPatch(state.face, panel.face ?? {}); state.caption = { ...saved.caption, ...(panel.caption ?? {}) }; state.caption2 = { ...saved.caption2, ...(panel.caption2 ?? {}) }; // The camera the panel was framed with, so a comic keeps its angles. if (panel.view) state.view = JSON.parse(JSON.stringify(panel.view)); refresh('all'); // Two frames: one to apply the pose, one to draw it. await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); captures.push(await captureImage({ scale, transparent: false })); } } finally { suspendHistory = false; applyState(saved, { full: true, sync: true }); } const columns = Math.max(1, Math.min(4, state.story?.columns ?? 2)); const gap = state.story?.gap ?? 12; const padding = state.story?.padding ?? 20; const rows = Math.ceil(captures.length / columns); const cellW = Math.max(...captures.map((item) => item.width)); const cellH = Math.max(...captures.map((item) => item.height)); // Once the sheet is more than one column wide, the reading order stops being // obvious from the layout alone, so the panels are numbered. A single column // (or two panels) reads top to bottom without help. const numbered = columns >= 2 && captures.length >= 3; if (numbered) await ensureCaptionFont(); const sheet = document.createElement('canvas'); sheet.width = padding * 2 + cellW * columns + gap * (columns - 1); sheet.height = padding * 2 + cellH * rows + gap * (rows - 1); const ctx = sheet.getContext('2d'); ctx.fillStyle = state.story?.sheetBackground ?? '#ffffff'; ctx.fillRect(0, 0, sheet.width, sheet.height); captures.forEach((capture, index) => { const x = padding + (index % columns) * (cellW + gap); const y = padding + Math.floor(index / columns) * (cellH + gap); ctx.drawImage(capture.canvas, x, y, cellW, cellH); if (numbered) drawPanelNumber(ctx, index + 1, x, y, cellW, cellH); }); const stamp = exporter.timestamp(); exporter.downloadBlob(await exporter.canvasToBlob(sheet), `bluebey-comic-${stamp}.png`); if (includeZip) { const files = await Promise.all(captures.map(async (capture, index) => ({ name: `panel-${String(index + 1).padStart(2, '0')}.png`, data: new Uint8Array(await capture.blob.arrayBuffer()), }))); exporter.downloadBlob(createZip(files), `bluebey-comic-${stamp}.zip`); } toast(`まんがを書き出しました(${captures.length}コマ)`); } /** A shared link restores the whole look; otherwise this is the start point. */ async function loadFromHash() { // 共有ボタンは `?s=`、ボタン「この見た目のリンクをコピー」は `#s=` を使う。 // Facebook は `#` 以降を落とすので、共有はクエリにする必要がある - どちらでも読む。 const query = new URLSearchParams(location.search).get('s'); const hash = location.hash.replace(/^#/, ''); const encoded = query ?? new URLSearchParams(hash).get('s'); if (!encoded) return false; const decoded = await decodeState(encoded); if (!decoded?.state) return false; suspendHistory = true; try { applyState(decoded.state, { full: true, sync: true }); } finally { suspendHistory = false; } return true; } function installTopbar() { // The panel itself is visible from the start, but each of its sections starts // collapsed (see `section()` in src/ui.js): the panel reads as a short list of // headings, and you open the one you want instead of scrolling past twelve. resizeViewport(); document.getElementById('btn-shot')?.addEventListener('click', () => app.actions.savePNG()); document.getElementById('btn-copy')?.addEventListener('click', () => app.actions.copyPNG()); document.getElementById('btn-help')?.addEventListener('click', toggleHelp); document.getElementById('btn-panel')?.addEventListener('click', togglePanel); document.getElementById('help-close')?.addEventListener('click', () => { toggleHelp(false); }); const help = document.getElementById('help'); help?.addEventListener('click', (event) => { if (event.target === help) toggleHelp(false); }); // --- the mouse on the character ---------------------------------------- // Dragging the model slides it across the view; holding Shift and dragging // turns it around. Bone posing lives in the panel, so the mouse never grabs a // bone (see `Rig` in src/rig.js). On a small screen the panel is a sheet over // the view, so a tap on the view first just dismisses the sheet. const dragRay = new THREE.Raycaster(); const dragNdc = new THREE.Vector2(); const dragHitAt = (event) => { const rect = canvas.getBoundingClientRect(); dragNdc.set( ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1, -((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 + 1, ); dragRay.setFromCamera(dragNdc, view.camera); const targets = [model.parts.eyeMesh, model.parts.mouthMesh, ...model.parts.body] .filter((mesh) => mesh?.visible); return dragRay.intersectObjects(targets, false)[0] ?? null; }; // World units per screen pixel, so the model keeps up with the cursor. const dragScale = () => { const camera = view.camera; const height = Math.max(1, canvas.clientHeight); if (camera.isOrthographicCamera) return (camera.top - camera.bottom) / camera.zoom / height; const distance = camera.position.distanceTo(view.controls.target); return (2 * distance * Math.tan((camera.fov * Math.PI) / 360)) / height; }; let modelDrag = null; canvas.addEventListener('pointerdown', (event) => { if (event.button !== 0 || event.target !== canvas) return; if (narrowScreenLayout.matches && !document.body.classList.contains('panel-hidden')) { togglePanel(); return; } if (!dragHitAt(event)) return; const root = state.pose.root ?? [0, 0, 0]; const master = state.pose.bones?.master ?? [0, 0, 0]; modelDrag = { pointerId: event.pointerId, spin: event.shiftKey, x: event.clientX, y: event.clientY, root: [...root], masterY: master[1] ?? 0, }; view.controls.enabled = false; stage.style.cursor = 'grabbing'; try { canvas.setPointerCapture(event.pointerId); } catch { /* ignore */ } }); canvas.addEventListener('pointermove', (event) => { if (!modelDrag || event.pointerId !== modelDrag.pointerId) return; const dx = event.clientX - modelDrag.x; const dy = event.clientY - modelDrag.y; if (modelDrag.spin) { const bones = { ...(state.pose.bones ?? {}) }; const master = bones.master ?? [0, 0, 0]; bones.master = [master[0], round2(modelDrag.masterY + dx * 0.7), master[2]]; state.pose.bones = bones; } else { const k = dragScale(); state.pose.root = [ round2(modelDrag.root[0] + dx * k), round2(modelDrag.root[1] - dy * k), modelDrag.root[2], ]; } app.panel?.onRigChanged?.(); app.needsRender = true; }); const endModelDrag = (event) => { if (!modelDrag || (event && event.pointerId !== modelDrag.pointerId)) return; modelDrag = null; view.controls.enabled = true; stage.style.cursor = ''; if (event) { try { canvas.releasePointerCapture(event.pointerId); } catch { /* ignore */ } } capturePose(); app.panel?.onRigChanged?.(); app.needsRender = true; }; canvas.addEventListener('pointerup', endModelDrag); canvas.addEventListener('pointercancel', endModelDrag); } function togglePanel() { document.body.classList.toggle('panel-hidden'); resizeViewport(); } // The panel is a side column on a wide screen and a bottom sheet on a small // one, where it starts closed so the 3D view fills the screen (see the // small-screen rules in src/style.css). When the window is resized across that // breakpoint, match the layout the other side expects instead of leaving the // panel stuck in the previous state. const narrowScreenLayout = window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coarse)'); narrowScreenLayout.addEventListener('change', (event) => { document.body.classList.toggle('panel-hidden', event.matches); resizeViewport(); }); function installKeys() { window.addEventListener('keydown', (event) => { // Undo/redo are the only shortcuts that need a modifier, so they are dealt // with before the "no modifiers" rule below. if ((event.ctrlKey || event.metaKey) && !event.altKey) { const combo = event.key.toLowerCase(); if (combo === 'z' || combo === 'y') { event.preventDefault(); restoreFromHistory(combo === 'y' || event.shiftKey ? 'redo' : 'undo'); return; } } if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement) return; switch (event.key) { case 's': case 'S': savePNG(); break; case 'c': case 'C': copyPNG(); break; case 'r': case 'R': app.actions.resetPose(); break; case 'g': case 'G': { const visible = !rig.helper.visible; rig.setGizmoVisible(visible); toast(visible ? 'ギズモを表示' : 'ギズモを隠しました'); app.needsRender = true; break; } case ' ': event.preventDefault(); app.actions.toggleAnimation(); break; case '?': toggleHelp(); break; case 'Tab': event.preventDefault(); togglePanel(); break; case '1': setCameraPreset('front'); break; case '2': setCameraPreset('threeQuarter'); break; case '3': setCameraPreset('side'); break; case '4': setCameraPreset('back'); break; case '5': setCameraPreset('top'); break; default: break; } }); } } function toggleHelp(force) { const help = document.getElementById('help'); if (help) help.hidden = force === undefined ? !help.hidden : !force; } /** A persistent message for problems that need the user to do something. */ function showNotice(text) { let node = document.getElementById('notice'); if (!node) { node = document.createElement('div'); node.id = 'notice'; document.body.append(node); } node.textContent = text; node.hidden = false; } /* ------------------------------------------------------------------- camera */ /** * Orbiting camera with both a perspective and an orthographic projection. * Only one of them is enabled at a time; switching copies the framing across so * the model does not jump. */ class ViewRig { constructor(domElement, onChange) { this.domElement = domElement; this.onChange = onChange; this.projection = 'persp'; this.controlsChanged = false; this.cameras = { // A tight near/far keeps depth precision high, which matters because the // outline hull is only a few hundredths of a unit away from the surface. // The far plane has to sit well past the furthest the camera may go, or // zooming all the way out slices the character on it - which is exactly // what the old `far = 120` did, because the zoom limit was also 120. persp: new THREE.PerspectiveCamera(30, 1, 1, 2000), ortho: new THREE.OrthographicCamera(-1, 1, 1, -1, -2000, 2000), }; this.controlsMap = {}; for (const [key, camera] of Object.entries(this.cameras)) { const controls = new OrbitControls(camera, domElement); controls.enableDamping = false; controls.enablePan = true; controls.minDistance = 1; controls.maxDistance = 400; controls.addEventListener('change', () => { this.controlsChanged = true; this.onChange?.(); }); this.controlsMap[key] = controls; } this.controlsMap.ortho.enabled = false; this.camera = this.cameras.persp; this.controls = this.controlsMap.persp; this.target = new THREE.Vector3(0, 1, 0); this.autoDistance = 12; // `orthoHeight` is where the orthographic camera is *now* - `apply()` writes // the camera's actual height back into it - so the framing `frame()` chose // needs its own field to measure the zoom against (see `zoomFactor`). this.orthoHeight = 6; this.autoOrthoHeight = 6; this.resize(); } /** Fit the model into view. */ frame(size, targetY) { this.target.set(0, targetY, 0); for (const controls of Object.values(this.controlsMap)) controls.target.copy(this.target); const fov = this.cameras.persp.fov * Math.PI / 180; const heightDistance = size.y / (2 * Math.tan(fov / 2)); const widthDistance = size.x / (2 * Math.tan(fov / 2) * Math.max(0.4, this.aspect)); this.autoDistance = Math.max(heightDistance, widthDistance) * 1.45; this.orthoHeight = size.y * 1.5; this.autoOrthoHeight = this.orthoHeight; for (const camera of Object.values(this.cameras)) { camera.position.set(0, targetY + this.autoDistance * 0.12, this.autoDistance); } this.resize(); } get aspect() { const width = this.domElement.clientWidth || 1; const height = this.domElement.clientHeight || 1; return width / height; } resize() { const width = this.domElement.clientWidth || window.innerWidth; const height = this.domElement.clientHeight || window.innerHeight; const aspect = width / height; this.cameras.persp.aspect = aspect; this.cameras.persp.updateProjectionMatrix(); this.setOrthoHeight(this.orthoHeight); } setOrthoHeight(height) { const ortho = this.cameras.ortho; const halfHeight = height / 2; const halfWidth = halfHeight * this.aspect; ortho.left = -halfWidth; ortho.right = halfWidth; ortho.top = halfHeight; ortho.bottom = -halfHeight; ortho.zoom = 1; ortho.updateProjectionMatrix(); } /** * How large the character looks now, relative to the auto-fit framing. * * 1 means "the default framing", which is where the panel's 大きさ sliders sit * and so where the screen-space outline's pixel width is written against. The * two projections scale differently - a perspective camera shows 1/distance as * much per world unit, an orthographic one 1/height, and OrbitControls zooms * the latter with `camera.zoom` rather than by moving it - so each is * normalised by its own auto-fit value. */ zoomFactor() { if (this.projection === 'ortho') { const ortho = this.cameras.ortho; const height = (ortho.top - ortho.bottom) / Math.max(ortho.zoom, 0.0001); return this.autoOrthoHeight > 0 && height > 0 ? this.autoOrthoHeight / height : 1; } const distance = this.camera.position.distanceTo(this.controls?.target ?? this.target); return this.autoDistance > 0 && distance > 0 ? this.autoDistance / distance : 1; } setProjection(kind) { if (kind === this.projection) return; const previous = this.camera; const next = kind === 'ortho' ? this.cameras.ortho : this.cameras.persp; next.position.copy(previous.position); next.quaternion.copy(previous.quaternion); this.controlsMap[this.projection].enabled = false; this.projection = kind; this.camera = next; this.controls = this.controlsMap[kind]; this.controls.enabled = true; this.controls.target.copy(this.target); this.resize(); this.onCameraChange?.(next, this.controls); this.controlsChanged = true; this.onChange?.(); } /** Apply the serialisable view state (azimuth/polar/zoom). */ apply(viewState, size) { const distance = viewState.distance > 0 ? viewState.distance : this.autoDistance; const targetY = viewState.targetY || this.target.y; this.target.set(viewState.targetX || 0, targetY, viewState.targetZ || 0); const spherical = new THREE.Spherical( distance, clamp(viewState.polar, 1, 179) * Math.PI / 180, viewState.azimuth * Math.PI / 180, ); const offset = new THREE.Vector3().setFromSpherical(spherical); for (const camera of Object.values(this.cameras)) { camera.position.copy(this.target).add(offset); camera.lookAt(this.target); } if (viewState.orthoHeight > 0) this.setOrthoHeight(viewState.orthoHeight); else if (size) this.setOrthoHeight(this.orthoHeight); for (const controls of Object.values(this.controlsMap)) { controls.target.copy(this.target); controls.update(); } this.orthoHeight = this.cameras.ortho.top - this.cameras.ortho.bottom; this.cameras.persp.updateProjectionMatrix(); this.cameras.ortho.updateProjectionMatrix(); } /** * Write where the camera actually is back into the serialisable view state. * * WHY: the mouse orbit and pan move the `controls`, not the state. Without this * the state kept the last camera the *panel* set, so any refresh of the view * snapped the camera back to it - which is what made picking a background jump * the camera. Keeping the two in step also means a shared link carries the * camera you framed. */ captureInto(viewState) { if (!viewState || typeof viewState !== 'object') return; const target = this.controls?.target ?? this.target; const spherical = new THREE.Spherical().setFromVector3( new THREE.Vector3().subVectors(this.camera.position, target), ); viewState.azimuth = Math.round(spherical.theta * RAD_TO_DEG * 100) / 100; viewState.polar = Math.round(spherical.phi * RAD_TO_DEG * 100) / 100; viewState.distance = Math.round(spherical.radius * 1000) / 1000; viewState.targetX = Math.round(target.x * 1000) / 1000; viewState.targetY = Math.round(target.y * 1000) / 1000; viewState.targetZ = Math.round(target.z * 1000) / 1000; if (this.projection === 'ortho') { const ortho = this.cameras.ortho; viewState.orthoHeight = Math.round((ortho.top - ortho.bottom) * 1000) / 1000; } } setAutoRotate(enabled, speed) { for (const controls of Object.values(this.controlsMap)) { controls.autoRotate = enabled; controls.autoRotateSpeed = speed; } } } /** * How far the screen-space outline may follow the zoom (see `outlineRadiusFor`). * * A clamp keeps a very deep zoom from turning the line into either a smear or * nothing at all, which is what the linear factor would do at the extremes of * the 大きさ slider. */ const OUTLINE_ZOOM_MIN = 0.25; const OUTLINE_ZOOM_MAX = 3; /** * The screen-space outline's radius, in pixels of the label buffer. * * The body's lines are inverted hulls, i.e. an offset in *world* units, so they * thicken as the character grows on screen and thin as it shrinks. This pass * works in pixels instead, so left alone its lines - the waist leaves and the * nose - keep one width at every zoom, and the leaf skirt reads as far too heavy * beside the body the moment you pull back. Scaling the radius by the rig's * `zoomFactor` makes the two behave the same way, with `outlinePixels` staying * the width at the auto-fit framing (so the slider keeps its meaning). * * `extra` carries whatever else changes the buffer's pixels-per-CSS-pixel: the * preview's buffer is `quality`x the CSS size (and `quality` itself follows the * display density and the automatic reductions), and an export renders the label * buffer at `scale`x. Passing that factor keeps the line a constant *CSS* width, * so `outlinePixels` reads as CSS pixels whichever buffer it lands in. */ function outlineRadiusFor(view, state, extra = 1) { const zoom = clamp(view.zoomFactor(), OUTLINE_ZOOM_MIN, OUTLINE_ZOOM_MAX); return (state.render.outlinePixels ?? 2) * zoom * extra; } /* ----------------------------------------------------------------- scenery */ function makeEnvironment(renderer) { const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 32; const ctx = canvas.getContext('2d'); const gradient = ctx.createLinearGradient(0, 0, 0, 32); gradient.addColorStop(0, '#ffffff'); gradient.addColorStop(0.5, '#ece7fa'); gradient.addColorStop(1, '#b9aade'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 64, 32); const texture = new THREE.CanvasTexture(canvas); texture.mapping = THREE.EquirectangularReflectionMapping; texture.colorSpace = THREE.SRGBColorSpace; const generator = new THREE.PMREMGenerator(renderer); const environment = generator.fromEquirectangular(texture).texture; texture.dispose(); generator.dispose(); return environment; } /** Round to 2 decimals: the precision the panel's prop sliders show. */ const round2 = (value) => Math.round(value * 100) / 100; const clampNumber = (value, lo, hi) => Math.min(hi, Math.max(lo, value)); export function backgroundOf(viewState) { if (viewState.background === 'transparent') return { mode: 'transparent' }; // A preset, a loaded photo, a drawn effect line or the phone's camera all // live in a DOM layer behind the WebGL canvas, so the renderer is left clear // and the exports paint the bitmap into the picture themselves (see // drawBackdropInto in main.js). if (viewState.background === 'preset' || viewState.background === 'image' || viewState.background === 'effect' || viewState.background === 'camera') { return { mode: 'transparent' }; } return { mode: 'solid', color: viewState.backgroundColor }; } /* ------------------------------------------------------------------- errors */ function setLoadingProgress(ratio) { const node = document.getElementById('loading-text'); if (node) node.textContent = `ぶるべーを読み込んでいます… ${Math.round(ratio * 100)}%`; } function hideLoading() { const node = document.getElementById('loading'); if (node) node.classList.add('done'); } function showLoadError(error) { const text = document.getElementById('loading-text'); const detail = document.getElementById('loading-error'); if (text) text.textContent = '読み込みに失敗しました。'; if (detail) { detail.textContent = `${error?.message ?? error}\n\n` + 'このページはローカルサーバー経由で開く必要があります。\n' + 'bluebey-studio フォルダで python serve.py を実行し、\n' + '表示された http://127.0.0.1:8000/ をブラウザで開いてください。'; } } const clamp = (value, lo, hi) => Math.min(hi, Math.max(lo, value)); const clamp01 = (value) => clamp(value, 0, 1);