シミュレーションの共通の仕組み

全記事のシミュレーションが共通で使っているプログラム(2D と 3D)を、コードと一緒に解説する。記事ごとのプログラムは、各記事の「プログラム」のパネルで読める。

どの記事のシミュレーションも、同じ 3 つの部品の上で動いている。描画の道具とシミュレーションの形を決める engine.ts、ページに取り付けて動かす mount.ts、GPU で計算して白黒の網点で描く gl.ts である。記事ごとのプログラムは、状態を作る init・1 コマ進めて描く frame・つまみが動いたときの change・画面に触れたときの pointer を書けば動く。

シミュレーションの形

記事のシミュレーションは、どれもこの形の 1 つの値として書かれている。題・操作の説明・つまみ・場面と、いくつかの関数が並ぶ。

シミュレーションの形(src/sims/kit/engine.ts の 107〜141 行)
export interface SimDef<S = any> {  title: string;  /** 操作の説明(1 文)。例:「水面をドラッグすると岩を置けます」 */  hint?: string;  /** 高さ ÷ 横幅。指定が無ければ 9/16 */  aspect?: number;  /** スマホ(横幅 520px 未満)の高さ ÷ 横幅。指定が無ければ 4/3 */  aspectMobile?: number;  /** GPU(WebGL2)で描くか。true なら api.gl が使え、api.ctx は上に重ねた透明な面になる */  gl?: boolean;  /** GL の描画面の解像度の倍率(画質 0・1・2 のとき)。既定 [0.5, 1, 端末の倍率(最大 2)]。整数の比で広げると網点に縦の筋が出ない */  glScale?: [number, number, number];  controls?: Control[];  scenes?: Scene<S>[];  /** 最初から動かすか(既定 true)。端末が「視差効果を減らす」のときは止まった状態で始まる */  autoplay?: boolean;  /** キーボードで受けるキー(KeyboardEvent.code)。描画面を選んでいるときだけ受ける */  keys?: string[];  init(api: Api): S;  frame(api: Api, s: S): void;  change?(key: string, api: Api, s: S): void;  /** 指は「たたく」か横のドラッグだけで、縦のドラッグを使わない(既定 false)。true だとスマホで縦になぞるとページが送れる。縦になぞり始めたときは pointer("cancel") が来る */  tap?: boolean;  /** ホイールで拡大縮小などをするか(既定 false)。true のときだけ描画面の上のホイールを受け、ページは送らない */  wheel?: boolean;  pointer?(type: "down" | "move" | "up" | "cancel" | "wheel", api: Api, s: S, e?: { deltaY: number }): void;  key?(type: "down" | "up", code: string, api: Api, s: S): void;  resize?(api: Api, s: S): void;  /** 画質が変わったとき。無ければ init で作り直す */  onQuality?(api: Api, s: S): void;  /** 片付け(GL の資源など)。ページを離れるときと作り直す前に呼ぶ */  dispose?(api: Api, s: S): void;} export function defineSim<S>(d: SimDef<S>): SimDef<S> { return d; }

init は最初の状態を作る。frame は前のコマから api.dt 秒だけ時間を進め、画面に描く。止まっているときや、端末の設定で動きを減らしているときは dt が 0 で呼ばれ、時間を進めずに今の状態だけを描く。

場面とつまみ

つまみは、範囲(数の大きさ)・切り替え・選択・ボタンの 4 種類。場面は、つまみの値をまとめて変えたうえで状態を作り直し、その場面に合わせた配置を作る。本文の「試す」のボタンは、この場面の key を呼んでいる。

場面とつまみ(src/sims/kit/engine.ts の 7〜24 行)
export type Control =  | { type: "range"; key: string; label: string; min: number; max: number; step?: number; value: number; unit?: string; digits?: number }  | { type: "toggle"; key: string; label: string; value: boolean }  | { type: "select"; key: string; label: string; value: string; options: { value: string; label: string }[] }  | { type: "button"; key: string; label: string }; /** 場面。つまみの値をまとめて変え(set)、apply で状態を作る。シミュレーションの下の「場面」と、本文の「試す」のボタン(::try)から呼ばれる */export interface Scene<S = any> {  key: string;  label: string;  set?: Record<string, Value>;  /** 場面に切り替えた直後に呼ぶ。init のあとに呼ばれる(reset: false なら今の状態のまま) */  apply?(api: Api, s: S): void;  /** true(既定)なら init で作り直してから apply を呼ぶ */  reset?: boolean;  /** 切り替えたときに画面の中に短く出す説明 */  note?: string;}

1 コマの流れ

シミュレーションは、画面に入っている間だけ動く。ブラウザが画面を描き直すたびに呼ぶ requestAnimationFrame で 1 コマずつ進め、画面の外に出たり、別のタブに移ったりすると止まる。

1コマの流れ(src/sims/kit/mount.ts の 151〜173 行)
const draw = (dt: number) => {  if (broken || lost) return;  try { api.dt = dt; api.t += dt; api.frame++; def.frame(api, state); } catch (e) { fail(e); return; }  if (readout.textContent !== api._readout) readout.textContent = api._readout;  dirty = false;};const loop = (now: number) => {  raf = -1; // 描いている最中に値が変わっても、次のコマを二重に予約しないようにする  if (!visible || document.hidden) { raf = 0; return; }  const real = last ? (now - last) / 1000 : 1 / 60; last = now;  const dt = Math.min(0.05, real);  if (playing) { measure(real); draw(dt); } else if (dirty) draw(0);  raf = playing || dirty ? requestAnimationFrame(loop) : 0;};const kick = () => { if (!raf && visible) { last = 0; raf = requestAnimationFrame(loop); } };const poke = () => { dirty = true; kick(); };const reset = () => {  if (state) { try { def.dispose?.(api, state); } catch {} }  api.t = 0; api.frame = 0;  try { state = def.init(api); } catch (e) { fail(e); }  poke();};const setPlaying = (on: boolean) => { playing = on; api.playing = on; playBtn.textContent = on ? "一時停止" : "再生"; playBtn.setAttribute("aria-pressed", String(!on)); poke(); };

1 コマの時間は最大 0.05 秒に抑えている。別のタブから戻ったときに、止まっていた何十秒分を一度に進めて計算が壊れないようにするためである。

物理は決まった刻みで進める

画面の描き直しの間隔は、端末によって 1 秒に 30 回から 120 回以上まで違う。その間隔のまま物理を進めると、端末によって結果が変わってしまう。そこで、物理の計算は決まった刻み h でくり返し、画面の 1 コマに何回分を進めるかだけを変える。

固定の刻み(src/sims/kit/engine.ts の 158〜167 行)
/** 固定の刻みで物理を進める。acc は持ち越しの時間を入れる箱。1 コマの最大回数を超えた分は捨てる(重い端末で止まらないように) */export function stepper(h: number, max = 8) {  let acc = 0;  return (dt: number, step: (h: number) => void) => {    acc += dt; let n = 0;    while (acc >= h && n < max) { step(h); acc -= h; n++; }    if (n >= max) acc = 0;    return n;  };}

1 コマのくり返しには上限があり、上限を超えた分は捨てる。遅い端末で計算が追いつかず、画面が固まるのを防ぐためである。

速さを測って画質を下げる

重いシミュレーションでも、ページが固まらないようにしている。再生中の 2 秒ごとに 1 秒あたりのコマ数を数え、40 を下回ったら画質を 1 段下げる(2 回まで)。画質が下がると、各記事のプログラムは粒の数や格子の細かさ、GPU で計算する画素の数を減らす。

速さを測って画質を下げる(src/sims/kit/mount.ts の 138〜150 行)
// 速さの測り方: 再生中で画面に入っている 2 秒ごとのコマ数。最初の 1.5 秒(GPU の準備)は数えないlet fpsT = 0, fpsN = 0, warm = 1.5, downs = 0;const measure = (realDt: number) => {  if (warm > 0) { warm -= realDt; return; }  fpsT += realDt; fpsN++;  if (fpsT >= 2) {    api.fps = fpsN / fpsT; fpsT = 0; fpsN = 0;    if (autoQ && api.fps < 40 && api.quality > 0 && downs < 2) {      setQuality((api.quality - 1) as 0 | 1 | 2, true);      downs++; warm = 1.5;    }  }};

開いてすぐの 1.5 秒は数えない。GPU の準備にかかる最初の遅れで、必要もなく画質を下げないためである。

描画面の大きさ

記事を開いたときは、描画面がパネルを除いた画面全体に広がる。記事ビューでは、横幅に合わせた比率の大きさになる。どちらの場合も、端末の画素の細かさ(最大 2 倍)に合わせて描く。

描画面の大きさ(src/sims/kit/mount.ts の 104〜131 行)
const size = () => {  // 描画面は整数の px で表示する(1201.5 px のような端数の幅に引き伸ばすと、1 列が重なって網点の並びがずれ、縦の線に見える)  const box = stage.getBoundingClientRect();  const w = Math.max(240, Math.floor(box.width));  let h: number;  if (api.full) h = Math.max(200, Math.floor(box.height));  else {    const asp = w < 520 ? def.aspectMobile ?? 4 / 3 : def.aspect ?? 9 / 16;    h = Math.round(w * asp);    if (stage.style.height !== `${h}px`) stage.style.height = `${h}px`;  }  const dpr = Math.min(2, window.devicePixelRatio || 1);  const cw = Math.round(w * dpr), ch = Math.round(h * dpr);  let changed = false;  if (c2d.width !== cw || c2d.height !== ch) { c2d.width = cw; c2d.height = ch; changed = true; }  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);  if (glc) {    const sc = (def.glScale ?? [0.5, 1, dpr])[api.quality];    const gw = Math.max(1, Math.round(w * sc)), gh = Math.max(1, Math.round(h * sc));    if (glc.width !== gw || glc.height !== gh) { glc.width = gw; glc.height = gh; changed = true; }    api.glw = gw; api.glh = gh;  }  for (const c of [c2d, glc]) if (c && (c.style.width !== `${w}px` || c.style.height !== `${h}px`)) { c.style.width = `${w}px`; c.style.height = `${h}px`; }  if (api.w !== w || api.h !== h) changed = true;  api.w = w; api.h = h;  if (changed && state) { try { def.resize?.(api, state); } catch (e) { fail(e); } }  if (changed) dirty = true;};

線の太さと文字の大きさは、横幅 720 px のときを基準にした長さ u で決める。スマホでは文字が小さくなりすぎないように、0.74 倍を下限にしている。

大きさの基準(src/sims/kit/engine.ts の 194〜196 行)
// スマホ(横 560px 未満)は比例で縮めると文字が 7px ほどになるので、0.74 倍を下限にする// 大きな画面(全体のシミュレーションで横 1440px など)でも文字が大きくなりすぎないよう、1.4 倍を上限にするapi.u = (n) => { const k = Math.min(1.4, Math.min(api.w, api.h * 1.78) / 720); return n * (api.w < 560 ? Math.max(0.74, k) : k); };
全体のシミュレーション(src/sims/kit/mount.ts の 319〜329 行)
// 全体のシミュレーション: 描画面を画面いっぱい(パネルを除いた広さ)にし、大きさは描画面の箱から決めるconst setApp = (on: boolean) => {  if (api.full === on && wrap.classList.contains("sim-app") === on) return;  wrap.classList.toggle("sim-app", on);  if (!pseudo && document.fullscreenElement !== wrap) api.full = on;  requestAnimationFrame(() => { size(); poke(); });};const onView = (e: Event) => { if (o.main) setApp((e as CustomEvent).detail?.view === "sim"); };window.addEventListener("ug:view", onView);if (o.main && document.documentElement.dataset.view === "sim") { wrap.classList.add("sim-app"); api.full = true; }

白と黒の網点

このメディアの画面は白と黒の 2 色だけでできている。水面の高さや光の強さのように、なめらかに変わる量は網点で表す。4×4 のマスに 0 から 15 までの順番を散らばるように振り、濃さがその順番の値より大きい画素だけを黒(または白)にする。濃さ 0.5 なら 16 画素のうち 8 画素が塗られ、離れて見ると中間の濃さに見える。新聞の写真と同じ考え方である。

網点(src/sims/kit/engine.ts の 332〜351 行)
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => (v + 0.5) / 16);let off: HTMLCanvasElement | null = null, offImg: ImageData | null = null;api.field = (cols, rows, val, o) => {  cols = Math.max(1, Math.floor(cols)); rows = Math.max(1, Math.floor(rows));  const get = typeof val === "function" ? val : (i: number, j: number) => (val as ArrayLike<number>)[j * cols + i];  const x = o?.x ?? 0, y = o?.y ?? 0, w = o?.w ?? api.w, h = o?.h ?? api.h, dither = o?.dither ?? true;  const doc = (globalThis as any).document as Document | undefined;  if (!doc) return;  if (!off || off.width !== cols || off.height !== rows) { off = doc.createElement("canvas"); off.width = cols; off.height = rows; offImg = null; }  const oc = off.getContext("2d")!;  if (!offImg) offImg = oc.createImageData(cols, rows);  const d = offImg.data;  const I = api.inkRGB.map((c) => Math.round(c * 255)), P = api.paperRGB.map((c) => Math.round(c * 255));  for (let j = 0, k = 0; j < rows; j++) for (let i = 0; i < cols; i++, k += 4) {    const v = get(i, j), on = dither ? v > BAYER[(i & 3) + (j & 3) * 4] : v >= 0.5, c = on ? I : P;    d[k] = c[0]; d[k + 1] = c[1]; d[k + 2] = c[2]; d[k + 3] = 255;  }  oc.putImageData(offImg, 0, 0);  const sm = ctx.imageSmoothingEnabled; ctx.imageSmoothingEnabled = false; ctx.drawImage(off, x, y, w, h); ctx.imageSmoothingEnabled = sm;};

GPU で描くシミュレーションも、同じ網点をシェーダー(GPU で画素ごとに動く小さなプログラム)の中で作る。

網点のシェーダー(src/sims/kit/gl.ts の 10〜21 行)
export const DITHER = `uniform vec3 uInk;uniform vec3 uPaper;float bayer4(vec2 p) {  ivec2 q = ivec2(mod(p, 4.0));  int i = q.x + q.y * 4;  int m[16] = int[16](0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5);  return (float(m[i]) + 0.5) / 16.0;}vec3 mono(float v, vec2 p) { return v > bayer4(p) ? uInk : uPaper; }vec3 monoCell(float v, vec2 p, float cell) { return v > bayer4(floor(p / cell)) ? uInk : uPaper; }`;

GPU で時間を進める

水面の波や生き物の模様のように、格子の各点の値を少しずつ進める計算は、GPU で画素ごとに同時に計算する。前の時刻の値を読む面と、次の時刻の値を書く面を 2 枚用意し、1 刻みごとに入れ替える。

読む面と書く面(src/sims/kit/gl.ts の 103〜114 行)
export function pingpong(gl: WebGL2RenderingContext, o: TexOpt) {  const a = texture(gl, o), b = texture(gl, o);  const fa = target(gl, a), fb = target(gl, b);  const pp = {    w: o.w, h: o.h, read: a, write: b, readFb: fa, writeFb: fb,    swap() { [pp.read, pp.write] = [pp.write, pp.read]; [pp.readFb, pp.writeFb] = [pp.writeFb, pp.readFb]; },    /** 書く面に描く準備(描く範囲を面の大きさに) */    bindWrite() { gl.bindFramebuffer(gl.FRAMEBUFFER, pp.writeFb); gl.viewport(0, 0, o.w, o.h); },    dispose() { gl.deleteTexture(a); gl.deleteTexture(b); gl.deleteFramebuffer(fa); gl.deleteFramebuffer(fb); },  };  return pp;}

場面の切り替え

場面のボタンや本文の「試す」のボタンを押すと、つまみの値を変え、状態を作り直し、場面の配置を作り、止まっていれば再生する。

場面の切り替え(src/sims/kit/mount.ts の 235〜257 行)
// 場面const sceneBtns: HTMLButtonElement[] = [];function applyScene(key: string) {  const sc = def.scenes?.find((x) => x.key === key);  if (!sc) return;  for (const [k, v] of Object.entries(sc.set ?? {})) { api.p[k] = v; ctlEls.get(k)?.(v); }  if (sc.reset !== false) reset();  try { for (const k of Object.keys(sc.set ?? {})) def.change?.(k, api, state); sc.apply?.(api, state); } catch (e) { fail(e); }  sceneBtns.forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.key === key)));  if (sc.note) showNote(sc.note, 3200);  if (!playing && !reduce) setPlaying(true);  emit("sim_scene", { scene: key });  poke();}if (def.scenes?.length) {  scenesBar.append(el("span", "sim-scenes-l", "場面"));  for (const sc of def.scenes) {    const b = el("button", "chip", sc.label); b.type = "button"; b.dataset.key = sc.key; b.setAttribute("aria-pressed", "false");    b.addEventListener("click", () => applyScene(sc.key));    sceneBtns.push(b); scenesBar.append(b);  }}

3Dの描き方

立体で見せる記事は、three.js(WebGL2で立体を描くための部品集)で物の形・光・影・視点を描く。three.jsが描いた絵は、そのままでは灰色の濃淡を持つので、最後に画面全体をもう一度だけ描き直して、白と黒の2色にする。記事ごとのプログラムは、物の位置や形を計算してthree.jsに渡すところまでを受け持つ。

描き直しでは、描いた絵の明るさを網点(または斜めの線)の濃さに置き換える。明るい面ほど点が多く、暗い面ほど少ない。光の計算は明るさをそのままの割合で持つので、網点の割合が目に見える明るさとほぼ比例する。

輪郭線は、画面の各点の奥行き(カメラからの距離)の2回微分から引く。物の縁では奥行きが急に変わり、面と面の折れ目では奥行きの傾きが変わる。どちらも2回微分が大きくなるので、そこに線を置く。線の色は、その場所の濃さと逆にする(明るい面の上では地の色の線、暗い所では線の色の線)。

白と黒への変換(src/sims/kit/three.ts の 33〜86 行)
// 描いたシーンの明るさ(0〜1)を、網点の濃さにする。網点の 1 目は画面の 1 px(CSS)。// 奥行きの 2 回微分が大きいところ(物の縁・面の折れ目)に輪郭線を引く。線の色は、そこの濃さと逆の色(明るい面には地の色の線)const POST_VS = /* glsl */ `out vec2 vUv;void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;const POST_FS = /* glsl */ `precision highp float;uniform sampler2D tColor;uniform sampler2D tDepth;uniform vec2 uRes;uniform vec3 uInk;uniform vec3 uPaper;uniform float uNear, uFar, uEdge, uStyle, uExposure, uGamma, uBg, uFogNear, uFogFar, uCell;out vec4 outColor;float bayer4(vec2 p) {  ivec2 q = ivec2(mod(p, 4.0));  int i = q.x + q.y * 4;  int m[16] = int[16](0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5);  return (float(m[i]) + 0.5) / 16.0;}float viewZ(vec2 uv) {  float z = texture(tDepth, uv).r * 2.0 - 1.0;  return 2.0 * uNear * uFar / (uFar + uNear - z * (uFar - uNear));}void main() {  vec2 px = gl_FragCoord.xy;  vec2 uv = px / uRes;  vec2 e = 1.0 / uRes;  float d = texture(tDepth, uv).r;  float L = dot(texture(tColor, uv).rgb, vec3(0.2126, 0.7152, 0.0722));  float v = pow(clamp(L * uExposure, 0.0, 1.0), uGamma);  float z = viewZ(uv);  if (d >= 0.999999) v = uBg;  else if (uFogFar > uFogNear) {    // 遠くを背景の濃さに溶かす。なめらかな濃さの変化が網点の段で縞に見えないよう、溶かす所だけ少し揺らす    float f = smoothstep(uFogNear, uFogFar, z);    v = mix(v, uBg, f) + (fract(sin(dot(px, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) * 0.12 * f;  }  bool ink;  if (uStyle < 0.5) ink = v > bayer4(floor(px / uCell));  else ink = fract((px.x - px.y) / (4.0 * uCell)) < v;  if (uEdge > 0.0) {    float zl = viewZ(uv - vec2(e.x, 0.0)), zr = viewZ(uv + vec2(e.x, 0.0));    float zd = viewZ(uv - vec2(0.0, e.y)), zu = viewZ(uv + vec2(0.0, e.y));    float lap = abs(zl + zr - 2.0 * z) + abs(zu + zd - 2.0 * z);    // 手前の側だけに線を引く(背景の側に二重に出さない)    bool front = z <= min(min(zl, zr), min(zd, zu)) + 0.5 * lap;    // 両側が奥にある 1 px の細さのもの(線・点)は、縁でなく物そのものなので塗りのまま残す    float t = 0.012 * z;    bool thin = (zl > z + t && zr > z + t) || (zd > z + t && zu > z + t);    if (front && !thin && lap > 0.012 * z / uEdge) ink = v < 0.5;  }  outColor = vec4(ink ? uInk : uPaper, 1.0);}`;

視点は、注目する点のまわりを回るカメラで動かす。1本の指(マウス)のドラッグで回し、ホイールと2本の指で寄る。指を離した後は0.3秒ほど惰性で回る。運転席や座席からの視点に切り替える記事では、この操作を止めてカメラを直接動かす。

視点の操作(src/sims/kit/three.ts の 88〜154 行)
/** 注目する点のまわりを回る視点。1 本指(マウス)のドラッグで回し、ホイールと 2 本指で寄る。2 本指のドラッグで注目点を動かす */export class Orbit {  target = new THREE.Vector3();  /** 水平の向き(rad)・見下ろす角度(rad)・注目点までの距離(m) */  yaw = 0.7; pitch = 0.45; dist = 12;  minDist = 1; maxDist = 400; minPitch = -0.05; maxPitch = 1.5;  /** 触っていないときにゆっくり回る速さ(rad/s・0 で回らない) */  auto = 0;  /** false にすると、シミュレーションが camera を直接動かす(運転席の視点など) */  enabled = true;  /** 2 本指で操作している間は true。シミュレーションは自分の指の操作をしない */  busy = false;  /** 最後に読者が視点を動かしてからの秒数(視点を自動で動かすかの判断に使える) */  idle = 99;  private drag = false; private lx = 0; private ly = 0; private vy = 0; private vp = 0;  private pts = new Map<number, { x: number; y: number }>(); private pinch = 0; private mid = { x: 0, y: 0 };  constructor(private cam: THREE.PerspectiveCamera) {}  /** シミュレーションの pointer から呼ぶ(物をつかまなかったとき)。wheel は e.deltaY を渡す */  pointer(type: string, api: Api, e?: { deltaY: number }) {    const p = api.pointer;    if (type === "wheel" && e) { this.dist = clamp(this.dist * Math.exp(e.deltaY * 0.0012), this.minDist, this.maxDist); this.idle = 0; return; }    if (this.busy) return;    if (type === "down") { this.drag = true; this.lx = p.x; this.ly = p.y; this.vy = 0; this.vp = 0; this.idle = 0; }    else if (type === "move" && this.drag) {      const k = 3.2 / Math.max(320, Math.min(api.w, api.h * 1.6));      const dy = -(p.x - this.lx) * k, dp = (p.y - this.ly) * k;      this.yaw += dy; this.pitch = clamp(this.pitch + dp, this.minPitch, this.maxPitch);      this.vy = dy; this.vp = dp; this.lx = p.x; this.ly = p.y; this.idle = 0;    } else if (type === "up" || type === "cancel") this.drag = false;  }  /** 2 本指の操作(描画面に直接付ける) */  touch(type: "down" | "move" | "up", id: number, x: number, y: number) {    if (type === "down") this.pts.set(id, { x, y });    else if (type === "move" && this.pts.has(id)) this.pts.set(id, { x, y });    else if (type === "up") this.pts.delete(id);    if (this.pts.size >= 2) {      const [a, b] = [...this.pts.values()];      const d = Math.hypot(a.x - b.x, a.y - b.y), m = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };      if (!this.busy || type === "down") { this.busy = true; this.drag = false; this.pinch = d; this.mid = m; return; }      if (this.pinch > 0 && d > 0) this.dist = clamp(this.dist * this.pinch / d, this.minDist, this.maxDist);      // 注目点を画面の平行に動かす(距離に比例)      const s = this.dist * 0.0018, right = new THREE.Vector3(Math.cos(this.yaw), 0, -Math.sin(this.yaw));      this.target.addScaledVector(right, -(m.x - this.mid.x) * s);      this.target.y += (m.y - this.mid.y) * s;      this.pinch = d; this.mid = m; this.idle = 0;    } else if (this.pts.size === 0) this.busy = false;  }  /** 視点を目標の値へ置く(場面の切り替えなど) */  set(o: { yaw?: number; pitch?: number; dist?: number; target?: [number, number, number] }) {    if (o.yaw !== undefined) this.yaw = o.yaw;    if (o.pitch !== undefined) this.pitch = clamp(o.pitch, this.minPitch, this.maxPitch);    if (o.dist !== undefined) this.dist = clamp(o.dist, this.minDist, this.maxDist);    if (o.target) this.target.set(...o.target);  }  update(dt: number, reduced: boolean) {    this.idle += dt;    if (!this.drag && !this.busy && (Math.abs(this.vy) > 1e-5 || Math.abs(this.vp) > 1e-5)) {      // 指を離した後の惰性(0.3 秒ほどで止まる)      this.yaw += this.vy; this.pitch = clamp(this.pitch + this.vp, this.minPitch, this.maxPitch);      const f = Math.exp(-dt * 10); this.vy *= f; this.vp *= f;    }    if (this.auto && !reduced && this.idle > 4) this.yaw += this.auto * dt;    const c = Math.cos(this.pitch);    this.cam.position.set(this.target.x + this.dist * c * Math.sin(this.yaw), this.target.y + this.dist * Math.sin(this.pitch), this.target.z + this.dist * c * Math.cos(this.yaw));    this.cam.lookAt(this.target);  }}
3Dの描画面(src/sims/kit/three.ts の 193〜336 行)
export function view3(api: Api, o: View3Options = {}): View3 {  const gl = api.gl;  if (!gl) throw new Error("WebGL2 が使えません");  let renderer = renderers.get(gl);  if (!renderer) {    renderer = new THREE.WebGLRenderer({ canvas: gl.canvas as HTMLCanvasElement, context: gl, antialias: false });    renderer.setPixelRatio(1);    renderer.shadowMap.type = THREE.PCFShadowMap;    renderers.set(gl, renderer);  }  const r = renderer;  r.setClearColor(0x000000, 1);  r.outputColorSpace = THREE.LinearSRGBColorSpace;  r.toneMapping = THREE.NoToneMapping;   const scene = new THREE.Scene();  const camera = new THREE.PerspectiveCamera(o.fov ?? 45, api.w / Math.max(1, api.h), o.near ?? 0.1, o.far ?? 2000);  const orbit = new Orbit(camera);  const sun = new THREE.DirectionalLight(0xffffff, 1.05);  sun.position.set(6, 10, 4);  const sky = new THREE.HemisphereLight(0xffffff, 0x000000, 0.4);  scene.add(sun, sun.target, sky);  let S = o.shadowSize ?? 20;  // 影の 1 目の大きさ(2S ÷ 2048)に合わせて、面から少し離して影を読む(平らな面に縞が出ないように)  const box = () => { Object.assign(sun.shadow.camera, { left: -S, right: S, top: S, bottom: -S, near: 0.5, far: S * 6 }); sun.shadow.camera.updateProjectionMatrix(); sun.shadow.normalBias = Math.max(0.02, 3 * S / 2048); };  box();  sun.shadow.mapSize.set(2048, 2048);  sun.shadow.bias = -0.0005;   const depth = new THREE.DepthTexture(1, 1);  depth.type = THREE.UnsignedIntType;  const rt = new THREE.WebGLRenderTarget(1, 1, { depthBuffer: true, depthTexture: depth, magFilter: THREE.NearestFilter, minFilter: THREE.NearestFilter });  const post = new THREE.ShaderMaterial({    glslVersion: THREE.GLSL3, vertexShader: POST_VS, fragmentShader: POST_FS, depthTest: false, depthWrite: false,    uniforms: {      tColor: { value: rt.texture }, tDepth: { value: depth }, uRes: { value: new THREE.Vector2(1, 1) },      uInk: { value: new THREE.Vector3() }, uPaper: { value: new THREE.Vector3() },      uNear: { value: camera.near }, uFar: { value: camera.far }, uEdge: { value: o.edges ?? 1 }, uStyle: { value: o.style === "hatch" ? 1 : 0 },      uExposure: { value: o.exposure ?? 1 }, uGamma: { value: o.gamma ?? 1 }, uBg: { value: o.bg ?? 0 },      uFogNear: { value: o.fog?.[0] ?? 0 }, uFogFar: { value: o.fog?.[1] ?? 0 }, uCell: { value: 1 },    },  });  const postScene = new THREE.Scene();  const postCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);  const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), post);  quad.frustumCulled = false;  postScene.add(quad);   // 2 本指の操作は、描画面(2D の面が上にある)に直接付ける  const top = api.ctx.canvas;  const at = (e: PointerEvent) => { const b = top.getBoundingClientRect(); return [e.clientX - b.left, e.clientY - b.top] as const; };  const onDown = (e: PointerEvent) => { if (e.pointerType === "touch") orbit.touch("down", e.pointerId, ...at(e)); };  const onMove = (e: PointerEvent) => { if (e.pointerType === "touch") orbit.touch("move", e.pointerId, ...at(e)); };  const onUp = (e: PointerEvent) => { if (e.pointerType === "touch") orbit.touch("up", e.pointerId, ...at(e)); };  top.addEventListener("pointerdown", onDown, true); top.addEventListener("pointermove", onMove, true);  top.addEventListener("pointerup", onUp, true); top.addEventListener("pointercancel", onUp, true);   const tmp = new THREE.Vector3(), ndc = new THREE.Vector2(), rc = new THREE.Raycaster();  let shadows = -1, w = 0, h = 0;  const shadowFrom = o.shadowFrom ?? 1;   const v: View3 = {    renderer: r, scene, camera, orbit, sun, sky, fov: o.fov ?? 45,    mat(tone, m = {}) {      const side = m.double ? THREE.DoubleSide : THREE.FrontSide;      if (m.shine) return new THREE.MeshPhongMaterial({ color: gray(tone), specular: gray(m.shine), shininess: 60, flatShading: !!m.flat, side });      return new THREE.MeshLambertMaterial({ color: gray(tone), flatShading: !!m.flat, side });    },    flat: (tone, m = {}) => new THREE.MeshBasicMaterial({ color: gray(tone), side: m.double ? THREE.DoubleSide : THREE.FrontSide }),    lineMat: (tone) => new THREE.LineBasicMaterial({ color: gray(tone) }),    sunAt(dir, center) {      const c = center ?? sun.target.position;      const d = new THREE.Vector3(...dir).normalize();      sun.target.position.copy(c);      sun.position.copy(c).addScaledVector(d, S * 3);      sun.target.updateMatrixWorld();    },    shadowSize(size) {      S = size; box();      const d = sun.position.clone().sub(sun.target.position).normalize();      sun.position.copy(sun.target.position).addScaledVector(d, S * 3);    },    project(p) {      if (Array.isArray(p)) tmp.set(p[0], p[1], p[2]); else tmp.copy(p);      // カメラの後ろの点は、画面に写すと前に折り返して見えるので on = false にする      const front = tmp.clone().applyMatrix4(camera.matrixWorldInverse).z < 0;      tmp.project(camera);      return { x: (tmp.x + 1) / 2 * api.w, y: (1 - tmp.y) / 2 * api.h, on: front && tmp.z > -1 && tmp.z < 1 && Math.abs(tmp.x) <= 1.05 && Math.abs(tmp.y) <= 1.05 };    },    ray(x, y) { ndc.set(x / api.w * 2 - 1, 1 - y / api.h * 2); rc.setFromCamera(ndc, camera); return rc; },    pick(x, y, objs) { return v.ray(x, y).intersectObjects(objs, true)[0] ?? null; },    onPlane(x, y, plane) { const out = new THREE.Vector3(); return v.ray(x, y).ray.intersectPlane(plane, out); },    render(api) {      // 試験で時間を一気に進めている間は描かない(最後のコマだけ描く)      if (api.fast) return;      const gw = api.glw, gh = api.glh;      if (gw !== w || gh !== h) {        w = gw; h = gh;        r.setSize(gw, gh, false);        rt.setSize(gw, gh);        post.uniforms.uRes.value.set(gw, gh);      }      const asp = api.w / Math.max(1, api.h);      camera.aspect = asp;      const kw = o.keepWidth ?? 1;      camera.fov = asp >= kw ? v.fov : Math.min(100, (2 * Math.atan((Math.tan((v.fov * Math.PI) / 360) * kw) / asp) * 180) / Math.PI);      camera.updateProjectionMatrix();      post.uniforms.uNear.value = camera.near; post.uniforms.uFar.value = camera.far;      post.uniforms.uCell.value = Math.max(1, Math.round(gw / Math.max(1, api.w)));      post.uniforms.uInk.value.set(...api.inkRGB); post.uniforms.uPaper.value.set(...api.paperRGB);      const sh = api.quality >= shadowFrom ? 1 : 0;      if (sh !== shadows) { shadows = sh; r.shadowMap.enabled = !!sh; sun.castShadow = !!sh; r.shadowMap.needsUpdate = true; }      if (orbit.enabled) orbit.update(api.dt, api.reduced);      r.setRenderTarget(rt); r.render(scene, camera);      r.setRenderTarget(null); r.render(postScene, postCam);      api.ctx.clearRect(0, 0, api.w, api.h);    },    set(p) {      const u = post.uniforms;      if (p.style !== undefined) u.uStyle.value = p.style === "hatch" ? 1 : 0;      if (p.edges !== undefined) u.uEdge.value = p.edges;      if (p.bg !== undefined) u.uBg.value = p.bg;      if (p.exposure !== undefined) u.uExposure.value = p.exposure;      if (p.gamma !== undefined) u.uGamma.value = p.gamma;      if (p.fog !== undefined) { u.uFogNear.value = p.fog?.[0] ?? 0; u.uFogFar.value = p.fog?.[1] ?? 0; }    },    dispose() {      top.removeEventListener("pointerdown", onDown, true); top.removeEventListener("pointermove", onMove, true);      top.removeEventListener("pointerup", onUp, true); top.removeEventListener("pointercancel", onUp, true);      scene.traverse((ob) => {        const m = ob as THREE.Mesh;        // 並べて描く物(InstancedMesh)は、並べ方の行列のバッファも GPU から消す(描画の道具を使い回すので、残すと次の場面に前の並びが出る)        if ((ob as THREE.InstancedMesh).isInstancedMesh) (ob as THREE.InstancedMesh).dispose();        m.geometry?.dispose?.();        const mats = Array.isArray(m.material) ? m.material : m.material ? [m.material] : [];        for (const x of mats) { for (const k of Object.keys(x)) { const t = (x as any)[k]; if (t && t.isTexture) t.dispose(); } x.dispose(); }      });      sun.shadow.map?.dispose();      rt.dispose(); depth.dispose(); post.dispose(); quad.geometry.dispose();      r.renderLists.dispose();    },  };  return v;}
プログラム全体を見る(src/sims/kit/engine.ts・356 行)

読み込んでいます

プログラム全体を見る(src/sims/kit/mount.ts・382 行)

読み込んでいます

プログラム全体を見る(src/sims/kit/gl.ts・117 行)

読み込んでいます

プログラム全体を見る(src/sims/kit/three.ts・362 行)

読み込んでいます

トップへ戻る

姉妹サイト

株式会社Game が運営するメディアです。