designpaca/apps/site/public/work/rfs-cfd/scripts/thermal-field.js
Yun Chan 413486958c
Some checks failed
ci / build (push) Has been cancelled
Upgrade RFS and Saebyeok showcases
2026-08-25 04:41:51 +09:00

760 lines
28 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);
/* ── 데모 조작 UI ────────────────────────────────────────────────
WebGL 이 없거나 모션을 줄여도 조작의 의미는 남아야 한다.
그래서 슬라이더·장면·실물 뷰 전환은 그래픽 초기화보다 먼저 묶는다. */
const controls = {
load: Number(document.getElementById("ctlLoad")?.value ?? 70) / 100,
supply: Number(document.getElementById("ctlSupply")?.value ?? 65) / 100,
};
const presetButtons = [...document.querySelectorAll("[data-field-preset]")];
const fieldState = document.getElementById("fieldState");
const fieldScene = document.getElementById("fieldScene");
const fieldWatch = document.getElementById("fieldWatch");
const fieldAction = document.getElementById("fieldAction");
const activePreset = () => presetButtons.find((button) => button.getAttribute("aria-pressed") === "true");
function updateReadout() {
const gap = Math.round((controls.load - controls.supply) * 100);
const selected = activePreset();
if (fieldScene) fieldScene.textContent = selected?.textContent.trim() || "수동 조정";
let state = "냉기와 발열이 맞서는 기준 상태입니다.";
let watch = "랙 배면";
let action = "부하와 급기를 따로 움직여 비교";
if (gap >= 35) {
state = "발열이 급기보다 크게 앞섭니다. 배면에서 천장으로 오르는 열기둥을 확인하십시오.";
watch = "랙 배면 · 천장 배기";
action = "급기 상향과 부하 재배분을 비교";
} else if (gap >= 15) {
state = "발열 우세 구간입니다. 랙 뒤에 머무는 고온 입자의 경로를 비교하십시오.";
watch = "랙 배면";
action = "급기량을 한 단계씩 높여 비교";
} else if (gap <= -15) {
state = "급기가 발열을 앞섭니다. FWU 제트가 만드는 저온 구간을 확인하십시오.";
watch = "FWU 제트 · 냉점";
action = "과냉 구간과 팬 운전을 함께 검토";
}
if (fieldState) fieldState.textContent = state;
if (fieldWatch) fieldWatch.textContent = watch;
if (fieldAction) fieldAction.textContent = action;
}
function matchPreset() {
const load = Math.round(controls.load * 100);
const supply = Math.round(controls.supply * 100);
let matched = false;
for (const button of presetButtons) {
const same = Number(button.dataset.load) === load && Number(button.dataset.supply) === supply;
button.setAttribute("aria-pressed", String(same));
matched ||= same;
}
if (!matched) presetButtons.forEach((button) => button.setAttribute("aria-pressed", "false"));
}
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));
matchPreset();
updateReadout();
};
el.addEventListener("input", sync);
sync();
}
bindSlider("ctlLoad", "load", "outLoad", (v) => `${v}%`);
bindSlider("ctlSupply", "supply", "outSupply", (v) => `${v}%`);
for (const button of presetButtons) {
button.addEventListener("click", () => {
const load = document.getElementById("ctlLoad");
const supply = document.getElementById("ctlSupply");
if (!(load instanceof HTMLInputElement) || !(supply instanceof HTMLInputElement)) return;
load.value = button.dataset.load || load.value;
supply.value = button.dataset.supply || supply.value;
load.dispatchEvent(new Event("input", { bubbles: true }));
supply.dispatchEvent(new Event("input", { bubbles: true }));
});
}
const visTabs = [...document.querySelectorAll("[data-vis-target]")];
const visPanels = [...document.querySelectorAll("[data-vis-panel]")];
for (const tab of visTabs) {
tab.addEventListener("click", () => {
const target = tab.dataset.visTarget;
for (const button of visTabs) button.setAttribute("aria-pressed", String(button === tab));
for (const panel of visPanels) panel.hidden = panel.dataset.visPanel !== target;
});
}
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 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);
})();