feat(showcase): 신규 쇼케이스 3종 — RFS · 트웬티온스 · 새벽청과
Some checks failed
ci / build (push) Failing after 1m52s

각 워크를 서로 다른 클라이언트로 보고 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 에 목록만 남겼다.
This commit is contained in:
Yun Chan 2026-08-24 12:51:48 +09:00
parent 123d9fc5a8
commit 4d813420dc
31 changed files with 5973 additions and 11 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View file

@ -0,0 +1,545 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>RFS — 실시간 유체 시뮬레이터 · 데이터센터 열관리 GPU 엔진</title>
<meta
name="description"
content="GPU Compute Shader 위에서 도는 δ-SPH 엔진. 파티클 100,000개를 60 FPS 로 계산해 서버랙 발열과 항온항습기 냉각을 실시간으로 반영한다. 메시 재생성 없이 설비 배치를 그 자리에서 검증한다."
/>
<meta property="og:title" content="RFS — 실시간 유체 시뮬레이터" />
<meta
property="og:description"
content="설비를 옮기면 유동이 그 자리에서 바뀐다. 데이터센터 열관리를 위한 실시간 GPU 유체 시뮬레이션 엔진."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#0e1114" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap"
/>
<link rel="stylesheet" href="./styles/main.css" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230E1114'/%3E%3Cpath d='M4 21c4-6 8 4 12-2s8 3 12-3' stroke='%233E8FB0' stroke-width='2.4' fill='none' stroke-linecap='round'/%3E%3Cpath d='M4 27c4-6 8 4 12-2s8 3 12-3' stroke='%238CC152' stroke-width='2.4' fill='none' stroke-linecap='round' opacity='.75'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip-link" href="#main">본문으로 건너뛰기</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#top">
<span class="wordmark-mark" aria-hidden="true">
<svg viewBox="0 0 28 20" role="presentation" focusable="false">
<path d="M2 13c3.5-5.5 7-1 10.5-5S21 5 26 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M2 18c3.5-5.5 7-1 10.5-5S21 10 26 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity=".5" />
</svg>
</span>
<span class="wordmark-text">RFS</span>
</a>
<nav class="nav" aria-label="주요 섹션">
<a href="#solver">솔버</a>
<a href="#equipment">장비</a>
<a href="#visualization">시각화</a>
<a href="#adopt">도입</a>
</nav>
<p class="head-meta mono">v0.13.0</p>
</div>
</header>
<main id="main">
<!-- ══════════════════════════════════════════════════════════
1 · 히어로 — 암실. 실제로 도는 열유동이 첫 화면을 덮는다
══════════════════════════════════════════════════════════ -->
<section class="band band-dark hero" id="top" aria-labelledby="heroHeading">
<div class="hero-stage" data-field-shell data-field-state="loading">
<canvas id="thermalField" aria-hidden="true"></canvas>
<!-- WebGL 이 없거나 모션을 줄이는 설정이면 이 렌더가 남는다.
폴백이 아니라 결과물이다 — RFS 실물의 파티클 뷰다 -->
<img
class="hero-fallback"
src="./assets/sph-sideview.webp"
width="691"
height="437"
alt="RFS 실물 파티클 뷰 — 서버랙을 통과한 공기가 데워져 위로 오르는 측면 단면"
/>
<div class="hero-grid" aria-hidden="true"></div>
<!-- 장비 형상. 셰이더가 쓰는 좌표와 같은 값을 CSS 변수로 넘긴다 —
그림과 라벨이 어긋나면 라벨이 거짓말이 된다 -->
<div class="stage-solid stage-rack" aria-hidden="true"></div>
<div class="stage-solid stage-fwu" aria-hidden="true"></div>
<div class="hero-scrim" aria-hidden="true"></div>
<div class="stage-tag stage-tag-fwu" aria-hidden="true"><span>FWU 급기</span></div>
<div class="stage-tag stage-tag-rack" aria-hidden="true"><span>서버랙</span></div>
</div>
<div class="wrap hero-copy">
<h1 id="heroHeading">
메시를 다시<br />
만들지 않는다
</h1>
<p class="hero-lead">
설비를 옮기면 유동이 그 자리에서 바뀐다. 데이터센터 열관리를 위한 실시간 GPU 유체
시뮬레이터.
</p>
<div class="hero-controls">
<div class="ctl">
<div class="ctl-head">
<label for="ctlLoad">서버랙 부하</label>
<output class="mono" id="outLoad" for="ctlLoad">70%</output>
</div>
<input type="range" id="ctlLoad" min="0" max="100" step="5" value="70" />
</div>
<div class="ctl">
<div class="ctl-head">
<label for="ctlSupply">FWU 급기량</label>
<output class="mono" id="outSupply" for="ctlSupply">65%</output>
</div>
<input type="range" id="ctlSupply" min="0" max="100" step="5" value="65" />
</div>
</div>
<p class="hero-note">
위 화면은 브라우저에서 도는 <strong>2D 축소판</strong>이다. 슬라이더를 움직이거나 화면을
문지르면 유동이 즉시 반응한다. 실물 RFS 는 Unity 6.4 위에서 3D δ-SPH 를 돌린다 —
파티클 100,000개, 60 FPS.
</p>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
2 · 문제 — 왜 기존 CFD 로는 안 되는가
══════════════════════════════════════════════════════════ -->
<section class="band band-light section-problem" id="problem" aria-labelledby="problemHeading">
<div class="wrap split">
<div class="split-lead">
<h2 id="problemHeading">
해가 나올 즈음에는<br />이미 다른 결정을 내린 뒤다
</h2>
</div>
<div class="split-body">
<p>
범용 CFD 도구는 시뮬레이션 한 건에 수 시간에서 수 일이 걸린다. 랙 한 대를 옮길 때마다
메시를 다시 만들고 처음부터 다시 풀어야 하므로, 운영 중에 답을 얻는 용도로는 쓰이지
않는다. 그래서 현장의 배치 변경은 대개 계산이 아니라 경험으로 결정된다.
</p>
<p>
RFS 는 정확도의 상한을 낮추는 대신 <strong>회신 시간</strong>을 바꾼 도구다.
발열량을 올리고, 급기 밸브를 조이고, 랙을 한 칸 옮기는 조작이 같은 화면 안에서
유동에 반영된다. 검증할 것이 "결과 파일"이 아니라 "지금 보고 있는 장면"이 된다.
</p>
<dl class="contrast">
<div>
<dt>범용 CFD 도구</dt>
<dd>배치를 바꾸면 메시 재생성 → 재계산. 한 건에 수 시간~수 일</dd>
</div>
<div>
<dt>RFS</dt>
<dd>배치·발열·제어 변경이 같은 세션 안에서 유동에 반영된다</dd>
</div>
</dl>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
3 · 솔버 — 암실. 사양을 숨기지 않는다
══════════════════════════════════════════════════════════ -->
<section class="band band-dark section-solver" id="solver" aria-labelledby="solverHeading">
<div class="wrap">
<p class="eyebrow">유체 엔진</p>
<h2 id="solverHeading">GPU Compute Shader 위의 δ-SPH</h2>
<p class="section-lead">
격자를 깔지 않는다. 공기를 입자로 놓고 이웃 입자끼리 밀도·압력·열을 주고받게 한다.
벽이 움직여도 격자를 다시 만들 일이 없다 — 실시간이 가능한 이유가 여기에 있다.
</p>
<ul class="spec-ledger">
<li>
<p class="spec-name">보간 커널</p>
<p class="spec-value">Wendland C2</p>
<p class="spec-note">Poly6·Spiky 혼용을 걷어내고 단일 경로로 통일했다</p>
</li>
<li>
<p class="spec-name">상태방정식</p>
<p class="spec-value">Tait</p>
<p class="spec-note">약압축성 가정. 압력 반복 없이 밀도에서 압력을 직접 얻는다</p>
</li>
<li>
<p class="spec-name">벽면 경계</p>
<p class="spec-value">Adami Ghost Particle</p>
<p class="spec-note">엣지·코너까지 덮는다. 경계에서 입자가 새던 결함을 해소한 방식</p>
</li>
<li>
<p class="spec-name">시간 적분</p>
<p class="spec-value">CFL 적응 서브스텝</p>
<p class="spec-note">유속이 빨라지면 스텝을 자동으로 잘게 쪼갠다</p>
</li>
<li>
<p class="spec-name">배속</p>
<p class="spec-value">0.1× — 10×</p>
<p class="spec-note">배속을 바꿔도 같은 물리 결과가 나오도록 시간 불변을 보장한다</p>
</li>
<li>
<p class="spec-name">열·습도</p>
<p class="spec-value">입자 간 전도 + 부력</p>
<p class="spec-note">습공기 밀도 보정을 포함한다</p>
</li>
<li>
<p class="spec-name">주기 경계</p>
<p class="spec-value">PBC · 최소 이미지 규약</p>
<p class="spec-note">셀 래핑으로 무한 반복 공간을 다룬다</p>
</li>
<li>
<p class="spec-name">이웃 탐색</p>
<p class="spec-value">공간 해시 그리드</p>
<p class="spec-note">SpatialHash 커널 1개가 전담한다</p>
</li>
</ul>
<div class="figures">
<figure>
<p class="fig-num mono">01</p>
<p class="fig-title">GPU 커널</p>
<p class="fig-val mono">53<span>+</span></p>
<p class="fig-note">Compute Shader 10개 · HLSL 67개로 분할</p>
</figure>
<figure>
<p class="fig-num mono">02</p>
<p class="fig-title">파티클</p>
<p class="fig-val mono">100,000</p>
<p class="fig-note">최대 512,000. 60 FPS 안정 구간 기준</p>
</figure>
<figure>
<p class="fig-num mono">03</p>
<p class="fig-title">파티클 1개의 크기</p>
<p class="fig-val mono">20<span>B</span></p>
<p class="fig-note">AoS 를 SoA 6개 버퍼로 갈라 얻은 값</p>
</figure>
<figure>
<p class="fig-num mono">04</p>
<p class="fig-title">테스트</p>
<p class="fig-val mono">2,000<span>+</span></p>
<p class="fig-note">499개 파일 · EditMode · PlayMode · Performance</p>
</figure>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
4 · 장비 — 라이트. 이 제품이 아는 물건들
══════════════════════════════════════════════════════════ -->
<section class="band band-light section-equipment" id="equipment" aria-labelledby="equipHeading">
<div class="wrap">
<h2 id="equipHeading">전산실에 있는 물건을 그대로 안다</h2>
<p class="section-lead">
유동만 푸는 것으로는 답이 안 나온다. 열을 만드는 것과 식히는 것이 각자의 제어 논리를
가지고 장 안에 들어가 있어야 배치 변경이 의미를 갖는다.
</p>
<div class="equip-list">
<article>
<h3>항온항습기 <span class="tag mono">FWU</span></h3>
<p>
PID 자동 제어로 목표 온도를 좇는다. 열교환은 ε-NTU 로 계산하고,
프리쿨링·쿨링 밸브와 댐퍼 모드를 각각 조작할 수 있다.
</p>
</article>
<article>
<h3>서버랙</h3>
<p>
CFM 기반으로 통과 공기에 열을 싣는다. 동적 전력 프로파일 5종을 갖고 있고,
장애 상황을 만들어 그때 열이 어디로 가는지 볼 수 있다.
</p>
</article>
<article>
<h3>팬 · 댐퍼 · 냉각코일</h3>
<p>
듀얼 원뿔 존 모델로 GPU 파티클 물리에 직접 개입한다.
후처리로 그린 화살표가 아니라 실제로 입자를 민다.
</p>
</article>
<article>
<h3>온도계</h3>
<p>
물리 기반 센서 응답 특성을 갖는다. 값이 즉시 튀지 않고 실제 계기처럼 따라온다.
Cool Zone 과 Hot Zone 을 자동으로 분류한다.
</p>
</article>
<article>
<h3>급기구 · 배기구</h3>
<p>
CFD inlet / outlet 패턴을 따른다. 경계에 걸린 파티클을 자동으로 재배치해
유입·유출이 끊기지 않게 한다.
</p>
</article>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
5 · 시각화 — 암실. 실제 렌더를 그대로 싣는다
══════════════════════════════════════════════════════════ -->
<section class="band band-dark section-vis" id="visualization" aria-labelledby="visHeading">
<div class="wrap">
<p class="eyebrow">보이는 방식</p>
<h2 id="visHeading">읽는 방법을 네 가지로 나눈다</h2>
<div class="vis-layout">
<ol class="vis-list">
<li>
<h3>파티클</h3>
<p>온도로 색을 매핑하고 발광과 LOD 를 건다. 공기가 어디서 데워지는지 그대로 보인다.</p>
</li>
<li>
<h3>트레일</h3>
<p>Ring Buffer 로 궤적을 남긴다. 한 순간이 아니라 지나온 경로를 본다.</p>
</li>
<li>
<h3>히트맵 슬라이스</h3>
<p>
온도·속도·압력·밀도·습도 다섯 물리량의 단면을 5패스 GPU 파이프라인으로 그린다.
<span class="mono">X</span> · <span class="mono">Y</span> ·
<span class="mono">Z</span> 키로 축을 바꾼다.
</p>
</li>
<li>
<h3>벡터 필드</h3>
<p>화살표 글리프와 스트림라인을 GPU 인스턴싱으로 띄운다. 방향과 세기를 함께 읽는다.</p>
</li>
</ol>
<div class="vis-shots">
<figure>
<img
src="./assets/sph-topview.webp"
width="689"
height="680"
loading="lazy"
decoding="async"
alt="RFS 상면 뷰 — 랙 상부에서 데워진 입자가 초록으로 퍼지고 주변 저온 입자는 청록으로 남는다"
/>
<figcaption>상면 · 랙 상부의 열기둥</figcaption>
</figure>
<figure>
<img
src="./assets/sph-sideview.webp"
width="691"
height="437"
loading="lazy"
decoding="async"
alt="RFS 측면 뷰 — 랙을 통과하며 데워진 공기가 오른쪽으로 밀려 나가는 단면"
/>
<figcaption>측면 · 랙을 통과한 공기</figcaption>
</figure>
<p class="shots-note">
두 장 모두 RFS 실물의 파티클 뷰다. 저온이 청록, 고온이 초록이다 —
이 페이지 첫 화면의 색도 같은 규칙을 쓴다.
</p>
</div>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
6 · 에디터 — 라이트
══════════════════════════════════════════════════════════ -->
<section class="band band-light section-editor" id="editor" aria-labelledby="editorHeading">
<div class="wrap split">
<div class="split-lead">
<h2 id="editorHeading">시뮬레이터가 아니라 작업 환경이다</h2>
<p class="section-lead">
배치를 바꿔 보려면 배치를 바꿀 수 있어야 한다. RFS Editor 는 Unity UI Toolkit 으로 만든
런타임 편집 환경이고, 시뮬레이션이 도는 채로 물건을 옮긴다.
</p>
</div>
<div class="split-body">
<dl class="feature-rows">
<div>
<dt>오브젝트 편집</dt>
<dd>이동·회전·스케일 핸들, 다중 선택, Undo / Redo, 드래그 앤 드롭 배치</dd>
</div>
<div>
<dt>속성 패널</dt>
<dd>실시간 속성 편집. 관제 시스템의 XD 태그를 여기서 바인딩한다</dd>
</div>
<div>
<dt>리플레이</dt>
<dd>
CSV 또는 XD API 를 데이터소스로 삼아 시계열을 재생하고, 실측 대비 정확도 리포트를
만든다
</dd>
</div>
<div>
<dt>AI Advisor</dt>
<dd>
26개 규칙의 Rule Engine 과 LLM 프로바이더 4종(Anthropic · OpenAI · Gemini ·
로컬 Ollama) 연동
</dd>
</div>
<div>
<dt>모니터링</dt>
<dd>PUE · COP · 전력 · CO₂ 대시보드, MetricsHUD 7섹션 26메트릭</dd>
</div>
<div>
<dt>프로젝트</dt>
<dd>생성·저장·불러오기, 다중 씬, glTF / GLB 3D 모델 임포트</dd>
</div>
</dl>
<div class="keys">
<p class="keys-title">손에 익는 키</p>
<ul>
<li><kbd>Space</kbd> 일시정지 · 재개</li>
<li><kbd>H</kbd> 히트맵</li>
<li><kbd>X</kbd><kbd>Y</kbd><kbd>Z</kbd> 슬라이스 축</li>
<li><kbd>F3</kbd> 성능 통계</li>
<li><kbd>Tab</kbd> UI 접기</li>
</ul>
</div>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
7 · 아키텍처 — 라이트. 5계층
══════════════════════════════════════════════════════════ -->
<section class="band band-dark section-arch" id="architecture" aria-labelledby="archHeading">
<div class="wrap">
<h2 id="archHeading">다섯 켜</h2>
<ol class="layers">
<li>
<p class="layer-id mono">L1</p>
<p class="layer-name">애플리케이션</p>
<p class="layer-body">RFS Editor · Simulation3D · AI Advisor · 리플레이</p>
</li>
<li>
<p class="layer-id mono">L2</p>
<p class="layer-name">파이프라인 · 컨트롤러</p>
<p class="layer-body">SimulationPipeline → 스테이지 10개 → 컨트롤러 26개</p>
</li>
<li>
<p class="layer-id mono">L3</p>
<p class="layer-name">GPU 컴퓨트</p>
<p class="layer-body">
SPHCore 8 · DomainExt 14 · SpatialHash 1 · TempGrid 8 · ObjectGrid 7 ·
Integration 5 · Scatter 4 · Trail 2 · GlobalSDF 3 · Coil 1
</p>
</li>
<li>
<p class="layer-id mono">L4</p>
<p class="layer-name">데이터 버퍼</p>
<p class="layer-body">SoA 버퍼 6개 · ParticleData 20B · 충돌 메시 데이터</p>
</li>
<li>
<p class="layer-id mono">L5</p>
<p class="layer-name">외부 통신</p>
<p class="layer-body">Socket.IO v2 (RFS-XD 관제 연동) · REST API · CSV 내보내기</p>
</li>
</ol>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
8 · 도입 — 라이트. 요구사양과 연락
══════════════════════════════════════════════════════════ -->
<section class="band band-light section-adopt" id="adopt" aria-labelledby="adoptHeading">
<div class="wrap split">
<div class="split-lead">
<h2 id="adoptHeading">돌려 보려면 무엇이 필요한가</h2>
<p class="section-lead">
DirectCompute 를 쓰므로 NVIDIA GPU 를 권장한다. 아래는 개발 저장소가 명시한 값
그대로다.
</p>
</div>
<div class="split-body">
<table class="req">
<caption class="sr-only">RFS 시스템 요구사항 — 최소 사양과 권장 사양</caption>
<thead>
<tr>
<th scope="col">항목</th>
<th scope="col">최소</th>
<th scope="col">권장</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">운영체제</th>
<td>Windows 10 64bit</td>
<td>Windows 11</td>
</tr>
<tr>
<th scope="row">GPU</th>
<td>GTX 1660 · VRAM 6GB</td>
<td>RTX 3060 이상 · VRAM 8GB 이상</td>
</tr>
<tr>
<th scope="row">메모리</th>
<td>16 GB</td>
<td>16 GB 이상</td>
</tr>
<tr>
<th scope="row">엔진</th>
<td colspan="2">Unity 6.4 (6000.4.5f1) · URP 17.2</td>
</tr>
</tbody>
</table>
<div class="adopt-cta">
<p>
도입 검토·기술 문의는 개발사인 트웬티온스로 연락하면 된다.
제품은 독점 소프트웨어이고, 평가판 제공 여부는 협의 사항이다.
</p>
<a class="btn" href="mailto:hello@twentyoz.kr?subject=RFS%20%EB%8F%84%EC%9E%85%20%EB%AC%B8%EC%9D%98">
RFS 도입 문의
</a>
<p class="adopt-fine mono">hello@twentyoz.kr · 070-4353-1190</p>
</div>
</div>
</div>
</section>
</main>
<footer class="band band-dark site-foot">
<div class="wrap foot-inner">
<div>
<p class="foot-name">RFS — Realtime Fluid Simulator</p>
<p class="foot-line">데이터센터 열관리를 위한 실시간 GPU 유체 시뮬레이션 엔진</p>
</div>
<dl class="foot-meta mono">
<div><dt>버전</dt><dd>0.13.0</dd></div>
<div><dt>엔진</dt><dd>Unity 6.4 · URP 17.2</dd></div>
<div><dt>개발</dt><dd>Twentyoz Co., Ltd.</dd></div>
<div><dt>라이선스</dt><dd>Proprietary</dd></div>
</dl>
</div>
<div class="wrap foot-fine">
<p>
이 페이지의 수치는 RFS 개발 저장소의 README 에 적힌 값을 옮긴 것이다.
실측 조건이 붙지 않은 정확도 지표는 싣지 않았다.
</p>
</div>
</footer>
<script src="./scripts/thermal-field.js" defer></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

View file

@ -0,0 +1,686 @@
/* ==========================================================================
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);
})();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,122 @@
/* ==========================================================================
RFS — Realtime Fluid Simulator · 디자인 토큰 SSOT
실시간 GPU 유체 시뮬레이션 엔진 (데이터센터 열관리) 프로모션 사이트
컨셉 : 암실과 사양서 — 유동이 있는 곳은 어둡고, 읽는 곳은 밝다
프리셋: darkroom-spec (신규 정의. dark-instrument 를 쓰지 않은 이유는 아래)
강조색: 계기 청색 1색. 라이트/다크는 규칙으로 정의된 한 쌍이므로 1종으로 센다
── 레퍼런스 실측 (2026-08-24, 1440px computed style)
R1 구조 · luminary.ai (Physics AI 시뮬레이션 플랫폼)
배경 #2A2C2F · GT Standard + GT Standard Mono 2패밀리
h1 72px/600 · 본문 16px · 최대÷본문 4.5배
radius 0px 1898회 지배 (8px 36 / 9999px 11)
컨테이너 1425 전폭 ↔ 1297 ↔ 974 · 섹션 14
히어로: 시뮬레이션 영상 1440×1440 이 첫 화면을 100% 덮는다
→ 가져온 원리: 시뮬레이션 결과가 히어로를 통째로 먹고 UI 는 무채색으로 물러난다.
색은 시각물 안에서만 소리친다
→ 안 가져온 것: 섹션 14개, 비어 있는 h1(접근성 실패), 'Physics AI Factory' 류 조어
R2 톤 · teenage.engineering / EP-133 (전자악기 — 다른 업종)
배경 #F9FAF9 · te-20 + TechnoType 2패밀리
radius 0px 2492회 지배 · 강조색 #FF5000 이 단 10회
밝은 페이지 안에 다크 블록 #0F0E12 가 18회 · 컨테이너 1308 ↔ 1440 전폭
최대 52.9px ÷ 본문 13.2px = 4.0배
→ 가져온 원리: 밝은 바탕 안에 어두운 밴드를 리듬으로 둔다. 강조색은 극히 아껴 쓴다
→ 안 가져온 것: h1 부재, 오렌지 색상 자체
R1 보조 · simscale.com (CFD SaaS)
배경 #F7F7F7 · Geist 1패밀리 · h1 49.78px/400 · 본문 15.38px (3.24배)
radius 0px 2558회 + pill 114회 · 컨테이너 1192 본문 ↔ 1425 전폭 교대
→ 라이트 배경 CFD 사이트의 반례로 기록. 별점 리뷰 배너는 가져오지 않는다
── dark-instrument 프리셋을 쓰지 않은 이유
이 저장소에 이미 dark-instrument 가 둘(Pulsegate 코발트, Synapse BCI 골드) 있다.
셋째를 같은 프리셋으로 만들면 그것은 이 브리프의 결정이 아니라 카테고리 평균이다.
RFS 는 파티클이 어두운 배경에서만 읽히지만(제품 실제 렌더 확인), 사양·도입 절차는
밝은 종이에서 읽는 것이 낫다. 그래서 한 페이지를 두 체제로 나눈다 — 그것이 감수한 리스크다.
── 강조색의 출처
제품 실물의 에디터 테마는 --theme-accent: #3b82f6 (Tailwind blue-500) 이다.
그대로 쓰면 '색을 고르지 않았다'는 신호를 그대로 수입하게 되므로, 채도를 낮추고
청록 쪽으로 틀어 계기 청색으로 옮겼다. 출발점은 제품의 실제 색이 맞다.
========================================================================== */
:root {
/* ── 타이포그래피 스케일 — 5단계 + 스케일 밖 디스플레이 1개 ── */
--step--1: 0.8125rem; /* 13px — 라벨·캡션·표 머리 */
--step-0: 1rem; /* 16px — 본문 (한글 하한) */
--step-1: 1.1875rem; /* 19px — 리드 문장 */
--step-2: clamp(1.375rem, 1.15rem + 1.1vw, 1.9375rem); /* 22 → 31px — 섹션 헤딩 */
--step-3: clamp(1.875rem, 1.4rem + 2.4vw, 3rem); /* 30 → 48px — 큰 헤딩 */
--display: clamp(2.5rem, 1.6rem + 4.4vw, 4.5rem); /* 40 → 72px — 히어로 전용 */
/* 최대 ÷ 본문 = 72 ÷ 16 = 4.5배. R1(luminary) 과 같은 비율이다 */
--leading-tight: 1.18;
--leading-normal: 1.7; /* 한글 본문 하한 1.6 */
--leading-loose: 1.85;
--measure: 34em; /* 한글 한 줄 25~40자 */
/* 라틴을 폴백 앞에 두면 라틴까지 한글 폰트로 그려진다 — Pretendard 는 둘 다 좋다 */
--font-body: Pretendard, "Pretendard Variable", -apple-system, system-ui, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, monospace;
/* ── 색 · 사양서(라이트) 체제 ── */
--surface: #f4f5f3;
--surface-2: #eaece8;
--surface-3: #dfe2dd;
--line: #d3d7d1;
--line-strong: #b6bcb5;
--ink: #14171a;
--ink-muted: #545c63; /* 6.23:1 */
--ink-faint: #626a70; /* 5.10:1 — 13px 캡션도 AA 를 지켜야 하므로 처음 값(#7C858C·3.43:1)에서 내렸다 */
--accent: #2a6b8a; /* 밝은 바탕 위 5.33:1 */
--accent-hover: #1f5069;
--accent-ink: #ffffff;
--accent-wash: #e3ecf1;
/* ── 색 · 암실(다크) 체제 — 위 값들의 규칙적 반전 ── */
--dark-surface: #0e1114;
--dark-surface-2: #14181c;
--dark-surface-3: #1b2126;
--dark-line: #262c31;
--dark-line-strong: #3a434a;
--dark-ink: #e8ecef;
--dark-ink-muted: #98a3ab; /* 7.35:1 */
--dark-ink-faint: #7b858c; /* 5.03:1 — 처음 값(#6D777E)은 4.14:1 로 미달이었다 */
--dark-accent: #3e8fb0; /* 어두운 바탕 위 5.20:1 */
--dark-accent-hover: #5aa6c4;
--dark-accent-ink: #071016;
--dark-accent-wash: #16303c;
/* ── 온도 컬러맵 — 캔버스 안에서만 쓰는 데이터 인코딩이다.
UI 강조색이 아니므로 강조색 카운트(하드 게이트 2)에서 제외한다.
제품 실물의 파티클 렌더가 저온 시안 → 고온 그린이라 그 언어를 따랐다 ── */
--temp-cold: #2f7d9e;
--temp-mid: #39a894;
--temp-warm: #8cc152;
--temp-hot: #d7d64a;
/* ── 형태 — R1·R2 둘 다 radius 0 이 지배한다. 어휘 1종으로 못박는다 ── */
--radius: 0;
/* ── 간격 — 저장소 SSOT 표준 ── */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--space-7: 6rem;
--space-8: clamp(4.5rem, 3rem + 6vw, 9rem);
--container: 1120px;
--container-wide: 1360px;
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
/* ── 모션 ── */
--dur-quick: 160ms;
--dur-normal: 420ms;
--dur-slow: 900ms;
--ease: cubic-bezier(0.2, 0, 0, 1);
}