/* ========================================================================== 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); })();