designpaca/apps/site/public/work/rfs-cfd/scripts/thermal-field.js
Yun Chan 4d813420dc
Some checks failed
ci / build (push) Failing after 1m52s
feat(showcase): 신규 쇼케이스 3종 — RFS · 트웬티온스 · 새벽청과
각 워크를 서로 다른 클라이언트로 보고 0~6단계를 따로 돌았다(토큰 체계 신규 정의 =
전체 경로). 셋 모두 라이트/다크 반전을 .band-light / .band-dark 가 --band-* 를
재정의하는 규칙으로 정의해 하드 게이트 4를 통과시켰다 — 반전이 섹션마다의 기분이
아니라 두 클래스가 만드는 규칙이다.

RFS — 실시간 유체 시뮬레이터 (rfs-cfd)
  프리셋 darkroom-spec · 컨셉 "암실과 사양서"
  R1 luminary.ai · R2 teenage.engineering EP-133 · 보조 simscale.com (전부 실측)
  히어로에서 브라우저가 실제로 Navier–Stokes 를 푼다. 순수 WebGL2, three.js 미사용.
  이류 → 온도 소스 → 부력·설비 힘 → 발산 → 압력 Jacobi → 투영 → 파티클 전진.
  격자 224×126 / 파티클 26,000 / Jacobi 22회, 좁은 화면은 절반으로 줄인다.
  prefers-reduced-motion 이면 시작하지 않고 그 자리에 RFS 실물 파티클 렌더가 남는다.
  단위계로 세 번 실패했다: dt 를 프레임 단위로 넘겨 파티클이 0.2초 만에 소멸했고,
  전부 급기구에서만 재생성해 실 한 가닥이 됐으며, 랙이 위로 뿜어 핫/콜드 아일이
  갈라지지 않았다. dt 를 초로 못박고 속도를 초당 격자 셀로 통일해 해결했다.
  수치는 전부 RFS 저장소 README 출처. 정확도 지표는 싣지 않았다 — 사내 검증 자료이고
  현재 통과 상태가 아니다.

트웬티온스 (twentyoz-corp)
  프리셋 field-record · 컨셉 "네 개의 현장"
  R1 samsara.com · R2 lars-mueller-publishers.com · R3 = 현행 twentyoz.kr 감사
  현행 사이트 실측: 대비 실패 172개 요소(9~11px 가 3.61:1), 한글 폰트 미지정으로
  한글이 시스템 기본으로 떨어짐, h1 자간 −0.06em(한글 상한의 세 배), 배경 #020202.
  'Digital Genetics' 은유가 산업용 IoT 사업을 가리고 있었다.
  유지한 자산은 radius 0 과 무채색·모노 기조.
  첫 화면에 회사 이름 대신 현장 선택기를 뒀다 — 라디오 그룹이라 JS 0바이트이고
  좌우 화살표 키가 그냥 먹는다. 파트너 19곳은 원본에 도메인 표기가 없어 나누지 않았다.

새벽청과 (saebyeok-cheonggwa)
  프리셋 market-board · 컨셉 "오늘 새벽 두 시의 값"
  R1 natoora.com · R2 phillips.com · R3 제철 12개월 띠
  커머스가 감추는 시세 변동을 히어로의 주인공으로 삼았다. 상품 사진은 아래로 내렸다.
  경매 시각·경락가격·등급·당도 표시 규정을 조사해 카피의 근거로 썼다.
  무화과 당도를 "—" 로 두고 값이 없는 게 아니라 표시가 허용되지 않는 품목이라고 적었다.
  등락은 색이 아니라 ▲▼보합 기호가 먼저다.
  표의 숫자가 예시임을 판 아래와 푸터 두 곳에 명시하고, 주소·전화는 비웠다(게이트 11).
  사진 4장은 codex 내장 image_gen 생성물이며 합계 341KB.

검증: Astro 빌드 통과, verify 체인 L0~L6 10계층 전부 통과,
showcase-audit 신규 3종 12/12 통과(320/390/768/1440).
성능 실측(RFS): JS 24KB · 총 704KB. 폰트만 530KB 로 예산(200KB) 초과 —
한글 웹폰트 자체가 원인이고 기존 cheongon-care 는 650KB 로 더 크다.
저장소 공통 baseline 이라 이번 판은 기록만 남기고 넘어간다.

기존 16종에서 발견된 70건(대비 미달·히어로 줄 수 초과)은 이번 범위를 넘어
design.md 에 목록만 남겼다.
2026-08-24 12:51:48 +09:00

686 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ==========================================================================
RFS 히어로 — 2D 열유동 데모 (WebGL2)
무엇인가 / 무엇이 아닌가
------------------------
이것은 브라우저에서 도는 **2D 축소판**이다. 실제 RFS 는 Unity 6.4 위에서
GPU Compute Shader 로 3D δ-SPH 를 돌린다(파티클 100,000개 / 60 FPS).
여기서 파는 것은 성능 수치가 아니라 "설비를 건드리면 유동이 즉시 바뀐다" 는
제품의 핵심 주장 하나이고, 그 주장은 2D 로도 정직하게 보일 수 있다.
페이지 캡션에 이 사실을 적어 두었다 — 데모가 제품인 척하면 그게 슬롭이다.
푸는 것
-------
비압축 Navier–Stokes 를 격자에서 푼다. Stam 의 Stable Fluids 절차다.
1) advect — semi-Lagrangian 이류 (속도·온도)
2) buoyancy — 온도 편차에 비례하는 부력 + 설비 소스 주입
3) divergence
4) pressure — Jacobi 반복
5) project — 압력 기울기를 빼서 발산을 0으로
그 위에 파티클을 띄워 속도장을 따라 흐르게 한다. 제품이 입자법(SPH)이므로
화면에 보이는 것도 입자여야 한다 — 격자만 칠하면 다른 제품의 그림이 된다.
설비 두 개가 실제로 장에 개입한다
· FWU(항온항습기) — 왼쪽 아래에서 찬 공기를 수평으로 분사
· 서버랙 — 가운데 블록. 통과하는 공기에 열을 싣고 위로 띄운다
========================================================================== */
(() => {
"use strict";
const canvas = document.getElementById("thermalField");
if (!canvas) return;
const shell = canvas.closest("[data-field-shell]");
const setState = (s) => shell && shell.setAttribute("data-field-state", s);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
if (reduced.matches) {
setState("static");
return;
}
/** @type {WebGL2RenderingContext | null} */
const gl = canvas.getContext("webgl2", {
alpha: true,
antialias: false,
depth: false,
stencil: false,
powerPreference: "low-power",
preserveDrawingBuffer: false,
});
if (!gl) {
setState("static");
return;
}
const floatLinear = gl.getExtension("OES_texture_float_linear");
if (!gl.getExtension("EXT_color_buffer_float")) {
setState("static");
return;
}
/* ── 해상도 예산 ─────────────────────────────────────────────────
좁은 화면은 대개 배터리로 돌고 열이 더 난다. 격자와 파티클을 함께 줄인다.
하드웨어 코어 수만 보면 요즘 폰이 전부 고사양으로 잡히므로 폭을 같이 본다. */
const narrow = window.innerWidth < 760;
const lowCore = (navigator.hardwareConcurrency || 8) <= 4;
const lite = narrow || lowCore;
const SIM_W = lite ? 128 : 224;
const SIM_H = lite ? 72 : 126;
const PARTICLES = lite ? 9000 : 26000;
const JACOBI = lite ? 14 : 22;
const DPR_CAP = lite ? 1.25 : 1.5;
/* ── 셰이더 유틸 ──────────────────────────────────────────────── */
const VERT_QUAD = `#version 300 es
in vec2 aPos;
out vec2 vUv;
void main() {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
function compile(type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
throw new Error(gl.getShaderInfoLog(sh) || "shader compile failed");
}
return sh;
}
function program(vsSrc, fsSrc) {
const p = gl.createProgram();
gl.attachShader(p, compile(gl.VERTEX_SHADER, vsSrc));
gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fsSrc));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(p) || "program link failed");
}
const uniforms = {};
const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < n; i++) {
const name = gl.getActiveUniform(p, i).name.replace(/\[0\]$/, "");
uniforms[name] = gl.getUniformLocation(p, name);
}
return { p, u: uniforms };
}
/* ── 설비 배치는 셰이더와 JS 가 같은 값을 봐야 한다 ── */
const RACK = { x0: 0.44, x1: 0.56, y0: 0.12, y1: 0.62 };
const FWU = { x: 0.055, y0: 0.1, y1: 0.34 };
const GLSL_LAYOUT = `
const vec4 RACK = vec4(${RACK.x0}, ${RACK.x1}, ${RACK.y0}, ${RACK.y1});
const vec3 FWU = vec3(${FWU.x}, ${FWU.y0}, ${FWU.y1});
bool inRack(vec2 p) {
return p.x > RACK.x && p.x < RACK.y && p.y > RACK.z && p.y < RACK.w;
}`;
/* 1) 이류 — 역추적 후 쌍선형 보간 */
const FS_ADVECT = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D uVel;
uniform sampler2D uSrc;
uniform vec2 uTexel;
uniform float uDt;
uniform float uDissipate;
${GLSL_LAYOUT}
void main() {
vec2 v = texture(uVel, vUv).xy;
vec2 back = vUv - uDt * v * uTexel;
back = clamp(back, uTexel * 0.5, 1.0 - uTexel * 0.5);
outColor = texture(uSrc, back) * uDissipate;
}`;
/* 2) 힘 — 부력 + 설비 소스. 랙 안은 발열, FWU 는 찬 공기를 분사.
uDt 는 **초**다. 속도의 단위는 초당 격자 셀이다. 둘을 프레임 단위와 섞으면
화면 주사율에 따라 물리가 달라진다 — 한 번 그렇게 짰다가 파티클이 0.2초 만에
사라졌다. 단위를 초로 못박은 것이 그 수리다. */
const FS_FORCE = `#version 300 es
precision highp float;
in vec2 vUv;
layout(location = 0) out vec4 outVel;
uniform sampler2D uVel;
uniform sampler2D uTemp;
uniform float uDt;
uniform float uTime;
uniform float uLoad; // 랙 부하 0..1
uniform float uSupply; // FWU 급기 0..1
uniform vec2 uPointer;
uniform float uPointerOn;
${GLSL_LAYOUT}
void main() {
vec2 v = texture(uVel, vUv).xy;
float t = texture(uTemp, vUv).r;
// 부력 — 주변보다 뜨거우면 뜬다
v.y += uDt * t * 190.0 * (0.7 + 0.3 * uLoad);
// FWU 급기: 왼쪽 벽에서 수평 제트
float band = smoothstep(FWU.y - 0.015, FWU.y + 0.03, vUv.y)
* (1.0 - smoothstep(FWU.z - 0.03, FWU.z + 0.015, vUv.y));
float jet = band * (1.0 - smoothstep(FWU.x, FWU.x + 0.2, vUv.x));
v.x += uDt * jet * 300.0 * uSupply;
v.y += uDt * jet * sin(uTime * 0.8 + vUv.y * 20.0) * 26.0;
// 서버랙: 전면(왼쪽)에서 빨아들여 배면(오른쪽)으로 밀어낸다.
// 위로 뿜게 두면 냉기와 열기가 랙을 사이에 두고 갈라지지 않아
// '핫 아일 / 콜드 아일' 이라는 이 업계의 기본 그림이 화면에 안 나온다.
if (inRack(vUv)) {
v.x += uDt * 240.0 * uLoad;
v.y += uDt * 45.0 * uLoad;
}
// 포인터 교란 — 손으로 유동을 흔들 수 있어야 '실시간'이 증명된다
if (uPointerOn > 0.5) {
vec2 d = vUv - uPointer;
float f = exp(-dot(d, d) * 520.0);
v += uDt * normalize(d + 1e-5) * f * 900.0;
}
v *= 1.0 - min(0.45, uDt * 0.5);
outVel = vec4(clamp(v, -260.0, 260.0), 0.0, 1.0);
}`;
/* 온도 소스 — 랙은 데우고 FWU 는 식힌다 */
const FS_TEMP_SRC = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D uTemp;
uniform float uDt;
uniform float uLoad;
uniform float uSupply;
${GLSL_LAYOUT}
void main() {
float t = texture(uTemp, vUv).r;
if (inRack(vUv)) {
t += uDt * 2.4 * uLoad;
}
float band = smoothstep(FWU.y - 0.015, FWU.y + 0.03, vUv.y)
* (1.0 - smoothstep(FWU.z - 0.03, FWU.z + 0.015, vUv.y));
float jet = band * (1.0 - smoothstep(FWU.x, FWU.x + 0.16, vUv.x));
t -= uDt * jet * 3.0 * uSupply;
// 천장 배기
t -= uDt * smoothstep(0.86, 1.0, vUv.y) * 0.9;
outColor = vec4(clamp(t, -0.3, 1.1), 0.0, 0.0, 1.0);
}`;
const FS_DIVERGENCE = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D uVel;
uniform vec2 uTexel;
void main() {
float l = texture(uVel, vUv - vec2(uTexel.x, 0.0)).x;
float r = texture(uVel, vUv + vec2(uTexel.x, 0.0)).x;
float b = texture(uVel, vUv - vec2(0.0, uTexel.y)).y;
float t = texture(uVel, vUv + vec2(0.0, uTexel.y)).y;
outColor = vec4(0.5 * (r - l + t - b), 0.0, 0.0, 1.0);
}`;
const FS_JACOBI = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D uPressure;
uniform sampler2D uDivergence;
uniform vec2 uTexel;
void main() {
float l = texture(uPressure, vUv - vec2(uTexel.x, 0.0)).r;
float r = texture(uPressure, vUv + vec2(uTexel.x, 0.0)).r;
float b = texture(uPressure, vUv - vec2(0.0, uTexel.y)).r;
float t = texture(uPressure, vUv + vec2(0.0, uTexel.y)).r;
float d = texture(uDivergence, vUv).r;
outColor = vec4((l + r + b + t - d) * 0.25, 0.0, 0.0, 1.0);
}`;
const FS_PROJECT = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D uPressure;
uniform sampler2D uVel;
uniform vec2 uTexel;
${GLSL_LAYOUT}
void main() {
float l = texture(uPressure, vUv - vec2(uTexel.x, 0.0)).r;
float r = texture(uPressure, vUv + vec2(uTexel.x, 0.0)).r;
float b = texture(uPressure, vUv - vec2(0.0, uTexel.y)).r;
float t = texture(uPressure, vUv + vec2(0.0, uTexel.y)).r;
vec2 v = texture(uVel, vUv).xy - vec2(r - l, t - b);
// 벽면 — 미끄럼 없음
if (vUv.x < uTexel.x * 1.5 || vUv.x > 1.0 - uTexel.x * 1.5) v.x = 0.0;
if (vUv.y < uTexel.y * 1.5) v.y = max(v.y, 0.0);
if (vUv.y > 1.0 - uTexel.y * 1.5) v.y = min(v.y, 0.0);
outColor = vec4(v, 0.0, 1.0);
}`;
/* 파티클 — 위치 텍스처를 속도장으로 갱신. 벗어나면 급기구로 되돌린다 */
const FS_PARTICLE_STEP = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D uPos;
uniform sampler2D uVel;
uniform vec2 uTexel;
uniform float uDt;
uniform float uTime;
${GLSL_LAYOUT}
float hash(vec2 p) {
return fract(sin(dot(p, vec2(41.31, 289.7))) * 43758.5453);
}
void main() {
vec4 s = texture(uPos, vUv);
vec2 p = s.xy;
float age = s.z;
// 속도는 초당 격자 셀이므로 텍셀 크기를 곱해 정규화 좌표로 옮긴다
vec2 v = texture(uVel, p).xy;
p += v * uDt * uTexel;
age -= uDt * 0.17; // 수명 약 4~9초
bool gone = age <= 0.0 || p.x < 0.004 || p.x > 0.996 || p.y < 0.004 || p.y > 0.996;
if (gone) {
float a = hash(vUv + uTime * 0.37);
float b = hash(vUv.yx + uTime * 0.71);
float c = hash(vUv * 3.1 + uTime * 0.19);
// 전부 급기구에서만 다시 나오면 같은 경로를 따라가 실 한 가닥이 된다.
// 방이 아니라 소용돌이 그림이 되는 원인이 이것이었다.
// 절반만 급기구에서 내보내고 나머지는 방 안 아무 데서나 되살린다.
if (c < 0.45) {
p = vec2(FWU.x + a * 0.035, mix(FWU.y, FWU.z, b));
} else {
p = vec2(0.03 + a * 0.94, 0.03 + b * 0.94);
}
age = 0.35 + c * 1.35;
}
outColor = vec4(p, age, 1.0);
}`;
const VS_PARTICLE_DRAW = `#version 300 es
precision highp float;
uniform sampler2D uPos;
uniform sampler2D uTemp;
uniform vec2 uPosSize;
uniform float uPointScale;
out float vTemp;
out float vAge;
void main() {
int id = gl_VertexID;
int w = int(uPosSize.x);
vec2 uv = (vec2(float(id % w), float(id / w)) + 0.5) / uPosSize;
vec4 s = texture(uPos, uv);
vTemp = texture(uTemp, s.xy).r;
vAge = clamp(s.z, 0.0, 1.0);
gl_Position = vec4(s.xy * 2.0 - 1.0, 0.0, 1.0);
gl_PointSize = uPointScale * (0.85 + 0.5 * vTemp);
}`;
const FS_PARTICLE_DRAW = `#version 300 es
precision highp float;
in float vTemp;
in float vAge;
out vec4 outColor;
uniform vec3 uCold;
uniform vec3 uMid;
uniform vec3 uWarm;
uniform vec3 uHot;
void main() {
vec2 d = gl_PointCoord - 0.5;
float r = dot(d, d);
if (r > 0.25) discard;
float soft = smoothstep(0.25, 0.02, r);
float t = clamp(vTemp * 1.15 + 0.16, 0.0, 1.0);
vec3 c = t < 0.34
? mix(uCold, uMid, t / 0.34)
: (t < 0.67 ? mix(uMid, uWarm, (t - 0.34) / 0.33)
: mix(uWarm, uHot, (t - 0.67) / 0.33));
float a = soft * smoothstep(0.0, 0.3, vAge) * (0.5 + 0.45 * t);
outColor = vec4(c * a, a);
}`;
/* ── 자원 ─────────────────────────────────────────────────────── */
let progs, quadVao, emptyVao, fbo;
const tex = {};
function makeTex(w, h, internal, format, type, data = null) {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, internal, w, h, 0, format, type, data);
const filter = floatLinear ? gl.LINEAR : gl.NEAREST;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return { t, w, h };
}
function pair(w, h, internal, format, type, data) {
return { read: makeTex(w, h, internal, format, type, data), write: makeTex(w, h, internal, format, type, data) };
}
const swap = (p) => {
const tmp = p.read;
p.read = p.write;
p.write = tmp;
};
function bindTarget(target) {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, target.t, 0);
gl.viewport(0, 0, target.w, target.h);
}
function useTex(unit, texture, loc) {
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, texture.t);
gl.uniform1i(loc, unit);
}
const drawQuad = () => {
gl.bindVertexArray(quadVao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const PART_W = 256;
const PART_H = Math.ceil(PARTICLES / PART_W);
const PART_N = PART_W * PART_H;
function init() {
progs = {
advect: program(VERT_QUAD, FS_ADVECT),
force: program(VERT_QUAD, FS_FORCE),
tempSrc: program(VERT_QUAD, FS_TEMP_SRC),
diverge: program(VERT_QUAD, FS_DIVERGENCE),
jacobi: program(VERT_QUAD, FS_JACOBI),
project: program(VERT_QUAD, FS_PROJECT),
pstep: program(VERT_QUAD, FS_PARTICLE_STEP),
pdraw: program(VS_PARTICLE_DRAW, FS_PARTICLE_DRAW),
};
// 화면을 덮는 삼각형 하나. 사각형 두 장보다 싸다
quadVao = gl.createVertexArray();
gl.bindVertexArray(quadVao);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
emptyVao = gl.createVertexArray();
fbo = gl.createFramebuffer();
tex.vel = pair(SIM_W, SIM_H, gl.RG16F, gl.RG, gl.HALF_FLOAT);
tex.temp = pair(SIM_W, SIM_H, gl.R16F, gl.RED, gl.HALF_FLOAT);
tex.pressure = pair(SIM_W, SIM_H, gl.R16F, gl.RED, gl.HALF_FLOAT);
tex.divergence = makeTex(SIM_W, SIM_H, gl.R16F, gl.RED, gl.HALF_FLOAT);
// 처음부터 방 전체에 흩어 둔다. 급기구에만 몰아 두면 첫 몇 초 동안
// 화면이 비어 있고, 그 사이에 사람들은 스크롤해 버린다
const seed = new Float32Array(PART_N * 4);
for (let i = 0; i < PART_N; i++) {
const atInlet = i % 5 === 0;
seed[i * 4] = atInlet ? FWU.x + Math.random() * 0.04 : 0.02 + Math.random() * 0.96;
seed[i * 4 + 1] = atInlet
? FWU.y0 + Math.random() * (FWU.y1 - FWU.y0)
: 0.02 + Math.random() * 0.96;
seed[i * 4 + 2] = 0.25 + Math.random() * 1.4;
seed[i * 4 + 3] = 1;
}
tex.part = pair(PART_W, PART_H, gl.RGBA32F, gl.RGBA, gl.FLOAT, seed);
// 파티클 위치 텍스처는 정확히 읽혀야 한다 — 보간하면 이웃 입자와 섞인다
for (const s of [tex.part.read, tex.part.write]) {
gl.bindTexture(gl.TEXTURE_2D, s.t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
}
}
/* ── 조작 입력 ────────────────────────────────────────────────── */
const controls = {
load: Number(document.getElementById("ctlLoad")?.value ?? 70) / 100,
supply: Number(document.getElementById("ctlSupply")?.value ?? 65) / 100,
};
function bindSlider(id, key, outId, fmt) {
const el = document.getElementById(id);
const out = document.getElementById(outId);
if (!el) return;
const sync = () => {
controls[key] = Number(el.value) / 100;
if (out) out.textContent = fmt(Number(el.value));
};
el.addEventListener("input", sync);
sync();
}
bindSlider("ctlLoad", "load", "outLoad", (v) => `${v}%`);
bindSlider("ctlSupply", "supply", "outSupply", (v) => `${v}%`);
const pointer = { x: 0, y: 0, on: 0 };
const onMove = (e) => {
const r = canvas.getBoundingClientRect();
const p = e.touches ? e.touches[0] : e;
pointer.x = (p.clientX - r.left) / r.width;
pointer.y = 1 - (p.clientY - r.top) / r.height;
pointer.on = 1;
};
canvas.addEventListener("pointermove", onMove, { passive: true });
canvas.addEventListener("pointerleave", () => (pointer.on = 0), { passive: true });
/* ── 렌더 루프 ────────────────────────────────────────────────── */
let running = false;
let raf = 0;
let last = 0;
const texel = [1 / SIM_W, 1 / SIM_H];
const cssVar = (name, fallback) => {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
};
const hexToRgb = (hex) => {
const h = hex.replace("#", "");
const n = parseInt(h.length === 3 ? h.replace(/./g, "$&$&") : h, 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
};
const ramp = {
cold: hexToRgb(cssVar("--temp-cold", "#2f7d9e")),
mid: hexToRgb(cssVar("--temp-mid", "#39a894")),
warm: hexToRgb(cssVar("--temp-warm", "#8cc152")),
hot: hexToRgb(cssVar("--temp-hot", "#d7d64a")),
};
function resize() {
const r = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP);
const w = Math.max(1, Math.round(r.width * dpr));
const h = Math.max(1, Math.round(r.height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
}
function step(dt) {
gl.disable(gl.BLEND);
// 감쇠는 프레임이 아니라 시간에 걸려야 한다. 60fps 기준값을 dt 로 환산한다
const decay = (per60) => Math.pow(per60, dt * 60);
// 1) 속도 이류
gl.useProgram(progs.advect.p);
gl.uniform2f(progs.advect.u.uTexel, texel[0], texel[1]);
gl.uniform1f(progs.advect.u.uDt, dt);
gl.uniform1f(progs.advect.u.uDissipate, decay(0.999));
useTex(0, tex.vel.read, progs.advect.u.uVel);
useTex(1, tex.vel.read, progs.advect.u.uSrc);
bindTarget(tex.vel.write);
drawQuad();
swap(tex.vel);
// 2) 온도 이류
gl.useProgram(progs.advect.p);
gl.uniform1f(progs.advect.u.uDissipate, decay(0.9975));
useTex(0, tex.vel.read, progs.advect.u.uVel);
useTex(1, tex.temp.read, progs.advect.u.uSrc);
bindTarget(tex.temp.write);
drawQuad();
swap(tex.temp);
// 3) 온도 소스
gl.useProgram(progs.tempSrc.p);
gl.uniform1f(progs.tempSrc.u.uDt, dt);
gl.uniform1f(progs.tempSrc.u.uLoad, controls.load);
gl.uniform1f(progs.tempSrc.u.uSupply, controls.supply);
useTex(0, tex.temp.read, progs.tempSrc.u.uTemp);
bindTarget(tex.temp.write);
drawQuad();
swap(tex.temp);
// 4) 부력 + 설비 힘
gl.useProgram(progs.force.p);
gl.uniform1f(progs.force.u.uDt, dt);
gl.uniform1f(progs.force.u.uTime, last * 0.001);
gl.uniform1f(progs.force.u.uLoad, controls.load);
gl.uniform1f(progs.force.u.uSupply, controls.supply);
gl.uniform2f(progs.force.u.uPointer, pointer.x, pointer.y);
gl.uniform1f(progs.force.u.uPointerOn, pointer.on);
useTex(0, tex.vel.read, progs.force.u.uVel);
useTex(1, tex.temp.read, progs.force.u.uTemp);
bindTarget(tex.vel.write);
drawQuad();
swap(tex.vel);
// 5) 발산
gl.useProgram(progs.diverge.p);
gl.uniform2f(progs.diverge.u.uTexel, texel[0], texel[1]);
useTex(0, tex.vel.read, progs.diverge.u.uVel);
bindTarget(tex.divergence);
drawQuad();
// 6) 압력 — Jacobi
gl.useProgram(progs.jacobi.p);
gl.uniform2f(progs.jacobi.u.uTexel, texel[0], texel[1]);
useTex(1, tex.divergence, progs.jacobi.u.uDivergence);
for (let i = 0; i < JACOBI; i++) {
useTex(0, tex.pressure.read, progs.jacobi.u.uPressure);
bindTarget(tex.pressure.write);
drawQuad();
swap(tex.pressure);
}
// 7) 투영
gl.useProgram(progs.project.p);
gl.uniform2f(progs.project.u.uTexel, texel[0], texel[1]);
useTex(0, tex.pressure.read, progs.project.u.uPressure);
useTex(1, tex.vel.read, progs.project.u.uVel);
bindTarget(tex.vel.write);
drawQuad();
swap(tex.vel);
// 8) 파티클 전진
gl.useProgram(progs.pstep.p);
gl.uniform2f(progs.pstep.u.uTexel, texel[0], texel[1]);
gl.uniform1f(progs.pstep.u.uDt, dt);
gl.uniform1f(progs.pstep.u.uTime, last * 0.001);
useTex(0, tex.part.read, progs.pstep.u.uPos);
useTex(1, tex.vel.read, progs.pstep.u.uVel);
bindTarget(tex.part.write);
drawQuad();
swap(tex.part);
}
function draw() {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.useProgram(progs.pdraw.p);
useTex(0, tex.part.read, progs.pdraw.u.uPos);
useTex(1, tex.temp.read, progs.pdraw.u.uTemp);
gl.uniform2f(progs.pdraw.u.uPosSize, PART_W, PART_H);
gl.uniform1f(progs.pdraw.u.uPointScale, Math.max(1.6, canvas.width / 620));
gl.uniform3fv(progs.pdraw.u.uCold, ramp.cold);
gl.uniform3fv(progs.pdraw.u.uMid, ramp.mid);
gl.uniform3fv(progs.pdraw.u.uWarm, ramp.warm);
gl.uniform3fv(progs.pdraw.u.uHot, ramp.hot);
gl.bindVertexArray(emptyVao);
gl.drawArrays(gl.POINTS, 0, PARTICLES);
gl.bindVertexArray(null);
}
function frame(now) {
if (!running) return;
// dt 는 초다. 탭이 뒤로 갔다 오면 한 프레임이 몇 초짜리가 되므로 상한을 둔다
const dt = Math.min(0.033, Math.max(0.004, (now - last) / 1000 || 0.016));
last = now;
resize();
step(dt);
draw();
raf = requestAnimationFrame(frame);
}
function start() {
if (running) return;
running = true;
last = performance.now();
raf = requestAnimationFrame(frame);
setState("live");
}
function stop() {
running = false;
cancelAnimationFrame(raf);
}
try {
init();
} catch (err) {
setState("static");
return;
}
// 화면 밖이면 그리지 않는다. 탭이 숨어도 마찬가지다
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) (e.isIntersecting ? start : stop)();
},
{ threshold: 0.02 },
);
io.observe(canvas);
document.addEventListener("visibilitychange", () => {
if (document.hidden) stop();
else if (canvas.getBoundingClientRect().top < window.innerHeight) start();
});
// 실행 중에 접근성 설정이 바뀌면 즉시 따른다
reduced.addEventListener("change", (e) => {
if (e.matches) {
stop();
setState("static");
io.disconnect();
}
});
new ResizeObserver(resize).observe(canvas);
})();