diff --git a/configs/jobs/demo_camera_modes.json b/configs/jobs/demo_camera_modes.json new file mode 100644 index 0000000000000000000000000000000000000000..6a729fba011b96033c33cfcb8f1b050a3d31f434 --- /dev/null +++ b/configs/jobs/demo_camera_modes.json @@ -0,0 +1,18 @@ +{ + "_doc": "발표용 카메라 모드 주행 영상 (2026-10-12). demo_videos 와 같은 자동 주행 20초 창 화면 녹화를 카메라 모드(--camera-mode)만 바꿔 깊이 · 시맨틱 · 흑백으로 — 모드는 위쪽 줄 카메라 센서 영상에만 걸리고 메인 주행 화면 · BEV 는 RGB 그대로. 시맨틱은 모델 옆 semantic.rgba 가 있어야 한다 (nusim recon semantic --location singapore-onenorth). 창이 화면에 떠서 혼자 달린다 — 사용자에게 미리 알릴 것. 결과: outputs/videos/camera_<모드>.mp4 (+ _poster.jpg).", + "name": "demo_camera_modes", + "vars": { + "map": "render map singapore-onenorth --no-sensors --", + "common": "--autopilot --front-cams --bev-pip --hide-panels --autoshot 400", + "dir": "outputs/videos", + "enc": "render encode --bitrate 2M" + }, + "steps": [ + {"name": "rec_depth", "run": "${map} ${common} --camera-mode depth --record-screen ${dir}/camera_depth/frames"}, + {"name": "enc_depth", "run": "${enc} ${dir}/camera_depth/frames ${dir}/camera_depth.mp4 --poster ${dir}/camera_depth_poster.jpg --caption '깊이 카메라 — 로그 거리 turbo 색 (0~80 m)'"}, + {"name": "rec_semantic", "run": "${map} ${common} --camera-mode semantic --record-screen ${dir}/camera_semantic/frames"}, + {"name": "enc_semantic", "run": "${enc} ${dir}/camera_semantic/frames ${dir}/camera_semantic.mp4 --poster ${dir}/camera_semantic_poster.jpg --caption '시맨틱 세그멘테이션 카메라 — nuScenes-lidarseg 라벨 (Cityscapes 색)'"}, + {"name": "rec_gray", "run": "${map} ${common} --camera-mode gray --record-screen ${dir}/camera_gray/frames"}, + {"name": "enc_gray", "run": "${enc} ${dir}/camera_gray/frames ${dir}/camera_gray.mp4 --poster ${dir}/camera_gray_poster.jpg --caption '흑백 카메라 — Rec.601 루마'"} + ] +} diff --git a/configs/jobs/ground_ablation.json b/configs/jobs/ground_ablation.json new file mode 100644 index 0000000000000000000000000000000000000000..8737066c748e87e494e595f254a0e3b223f870b6 --- /dev/null +++ b/configs/jobs/ground_ablation.json @@ -0,0 +1,11 @@ +{ + "_doc": "지면 모델 (HUGSIM 다중 평면 지면, nusim/recon/ground.py) 효과 점검 (2026-10-10). scene-0003 을 맵 배치와 같은 프리셋(day_map, 15k)으로 지면 모델 없이 / λ 10 / λ 100 학습하고, 원래 경로 평가(eval)와 실제 차선 변경 시점(onenorth_0003 타일에서 뺀 주행 0127·0969, 옆 약 4 m — tile-eval)을 비교한다. L_ground 는 m² 단위 분산이라 노면 잡음 0.05 m 이면 0.0025 — 측광 손실(~0.1)과 맞추려면 λ 10~100. 맵 배치(day_map)가 끝난 뒤에 돌린다 (사용자 결정 2026-10-10: GPU 실험은 배치 뒤).", + "name": "ground_ablation", + "vars": {"base": "recon train scene-0003 --preset day_map", "out": "outputs/gs_exp/ground"}, + "steps": [ + {"name": "base", "run": "${base} --out ${out}/base"}, + {"name": "g10", "run": "${base} --ground-lambda 10 --out ${out}/g10"}, + {"name": "g100", "run": "${base} --ground-lambda 100 --out ${out}/g100"}, + {"name": "lane", "run": "recon tile-eval onenorth_0003 --dir ${out}/base --dir ${out}/g10 --dir ${out}/g100 --out ${out}/tile_eval"} + ] +} diff --git a/configs/jobs/veh_60k_v2.json b/configs/jobs/veh_60k_v2.json new file mode 100644 index 0000000000000000000000000000000000000000..b7bfc07fbd22aef71d1e0e9b59facbe358136cee --- /dev/null +++ b/configs/jobs/veh_60k_v2.json @@ -0,0 +1,14 @@ +{ + "_doc": "차량 동시 학습 60k 재학습 (2026-10-06). veh_60k 는 MCMC 재배치가 객체 가우시안을 다른 곳으로 옮겨 차량 35대 중 17대만 남았다 → 재배치 판정 직전에 객체 가우시안 불투명도 하한(train.py). 3k 점검에서 35대 전부 남음.", + "name": "veh_60k_v2", + "steps": [ + { + "name": "train", + "run": "recon train scene-0003 --strategy mcmc --depth-lambda 0.1 --objects vehicles --eval-every 5000 --out outputs/gs/scene-0003_veh60p" + }, + { + "name": "eval", + "run": "recon eval scene-0003 --dir outputs/gs/scene-0003_veh60p --geom" + } + ] +} diff --git a/src/nusim/nusim/__init__.py b/src/nusim/nusim/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..df255b18cd065d9e08ddf4cb81d1add01c7d83db --- /dev/null +++ b/src/nusim/nusim/__init__.py @@ -0,0 +1,33 @@ +"""nuScenes 싱가포르 맵 기반 Vulkan 3DGS closed-loop 시뮬레이터. + +레이어 구조 (docs/architecture.md 참고): + + data/ nuScenes 읽기 — scene 인덱스, 카메라 리그, ego 궤적 + maps/ 맵 확장(map expansion) — 차선 그래프, 주행가능영역 + recon/ 3DGS 재구성(학습) — gsplat 기반 + io/ .ply / .splat 직렬화 — 학습기와 Vulkan 렌더러 사이의 교환 포맷 + sim/ closed-loop 환경 — ego 상태 적분, 6카메라 관측 생성 + cli/ 커맨드라인 진입점 +""" + +__version__ = "0.1.0" + +# 6개 카메라. nuScenes 의 표준 이름이자 이 프로젝트 전체에서 쓰는 정준 순서다. +# 순서가 바뀌면 멀티뷰 텐서의 축 의미가 달라지므로 여기서만 정의한다. +CAMERAS = ( + "CAM_FRONT_LEFT", + "CAM_FRONT", + "CAM_FRONT_RIGHT", + "CAM_BACK_RIGHT", + "CAM_BACK", + "CAM_BACK_LEFT", +) + +# 싱가포르 맵 3종. boston-seaport 는 이 프로젝트의 범위 밖이다. +SINGAPORE_MAPS = ( + "singapore-onenorth", + "singapore-queenstown", + "singapore-hollandvillage", +) + +__all__ = ["CAMERAS", "SINGAPORE_MAPS", "__version__"] diff --git a/src/nusim/nusim/aug/__init__.py b/src/nusim/nusim/aug/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..80f610bc9e6a3ca2670849e9335799dd36c9494c --- /dev/null +++ b/src/nusim/nusim/aug/__init__.py @@ -0,0 +1 @@ +"""데이터셋 다양성 증강 — 재구성 장면으로 새 카메라 시점(rig_aug), 생성 모델로 밤·비 → 낮(translate).""" diff --git a/src/nusim/nusim/aug/artifixer_prep.py b/src/nusim/nusim/aug/artifixer_prep.py new file mode 100644 index 0000000000000000000000000000000000000000..bf0a7b87b0baa0b9890eb9bad296be67103aec92 --- /dev/null +++ b/src/nusim/nusim/aug/artifixer_prep.py @@ -0,0 +1,128 @@ +"""ArtiFixer(nv-tlabs/artifixer, SIGGRAPH 2026) 입력 만들기 — 시점 변형 렌더를 확산 모델로 고치는 실험 (nusim aug artifixer-prep). + +ArtiFixer 는 3D 재구성 렌더(+불투명도)와 실제 참조 사진 몇 장, 카메라 자세를 받아 렌더의 흐림 · 구멍 · 떠다니는 조각을 +자기회귀 비디오 확산(Wan2.1)으로 고친다. 원래 파이프라인은 COLMAP → 3DGRUT 를 새로 학습하지만, 여기서는 우리 구간 모델 +(outputs/gs_day/<씬>/seg)의 gsplat 렌더를 그대로 넣는다 — model_eval/datasets/reconstructed_colmap_eval.py 형식: + + /split.json {"test": {: {transforms_path, image_root, render_dir, opacity_dir, + selected_indices_path, target_indices_path, prompt_path, camera_scale, has_gt}}} + /transforms.json OPENCV 내부 파라미터 + frames[i].transform_matrix (NeRFStudio/OpenGL c2w, 미터) + /images/ 참조 사진 (원래 자세의 실제 nuScenes 사진, 렌더 해상도로 줄임) + /renders/{i:05d}.png 렌더 (색 보정 · 하늘 합성 = nusim recon eval 과 같은 영상) + /opacity/{i:05d}.png 렌더 불투명도 α (하늘 합성 전) + /selected_indices.json 참조(실제 사진) 프레임 = 0..N-1 + /target_indices.json 고칠 프레임 = 변형 자세 N..2N-1 + /caption.txt 캡션 — 임베딩(caption.h5)은 artifixer 환경에서 UMT5 로 (scripts/artifixer_caption.py) + +자세는 학습이 보정한 외부 파라미터(camera_adjust)를 적용한 렌더 자세 그대로다. 미터 단위라 metric_scale = 1, +camera_scale = 0.01 (ArtiFixer prepare_colmap_artifixer_inputs 의 DEFAULT_CAMERA_CONDITIONING_SCALE). +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import replace +from pathlib import Path + +import numpy as np + +LOG = logging.getLogger(__name__) +CAMERA_CONDITIONING_SCALE = 0.01 +OPENCV_TO_OPENGL = np.diag([1.0, -1.0, -1.0, 1.0]) +DEFAULT_CAPTION = ("A daytime dashcam video driving along a tree-lined street in Singapore, asphalt road with white " + "lane markings, curbs, sidewalks, trees and buildings, clear sky, realistic photo.") + + +def prepare(scene_name: str, model_dir: Path, variant: dict, out: Path, cam: str = "CAM_FRONT", + width: int | None = None, max_frames: int | None = None, timestamps: set | None = None, start: int = 0, caption: str = DEFAULT_CAPTION, + tag: str = "", log=LOG.info) -> dict: + import torch + from PIL import Image + + from nusim.aug.rig_aug import transform_calib + from nusim.cli.main import _model_data, _model_objects, _model_views + from nusim.io.ply import read_ply + from nusim.recon.dataset import build_views + from nusim.recon.train import CameraAdjust, ColorCorrection, SkyCube, _to_params, composite_sky, render + + model_dir = Path(model_dir) + out.mkdir(parents=True, exist_ok=True) + for d in ("images", "renders", "opacity"): + (out / d).mkdir(exist_ok=True) + g = read_ply(model_dir / "point_cloud.ply") + vk = _model_views(model_dir) + sub, ds = _model_data(scene_name, model_dir) + kw = dict(downscale=vk["downscale"], frame_stride=vk["frame_stride"], keyframes_only=vk["keyframes_only"], + objects=_model_objects(model_dir), load_images=False, mask_dynamic=False, log=LOG.debug) + # 학습과 같은 뷰 번호 (색 보정의 프레임별 게인이 이 번호) — 그중 한 카메라만 쓴다 + real = build_views(sub, ds, g.origin, **kw) + vsub = replace(sub, calib={c: (transform_calib(k, variant.get("shift_m", (0, 0, 0)), variant.get("rot_deg", (0, 0, 0))) + if c == cam else k) for c, k in sub.calib.items()}) + var = build_views(vsub, ds, g.origin, **kw) + from nusim import CAMERAS + + cid = CAMERAS.index(cam) + rv = [v for v in real.all if v.cam_id == cid][start:] + tv = [v for v in var.all if v.cam_id == cid][start:] + if timestamps is not None: # 구간 모델이 '맡는' 프레임만 (nusim.aug.map_aug.assign_front_frames) + keep = [i for i, v in enumerate(rv) if v.pose.timestamp in timestamps] + rv, tv = [rv[i] for i in keep], [tv[i] for i in keep] + if max_frames: + rv, tv = rv[:max_frames], tv[:max_frames] + if width and width != rv[0].width: # 8 GB VRAM: ArtiFixer 토큰 수가 해상도에 비례 — 렌더 해상도 자체를 줄인다 + s = width / rv[0].width + + def small(v): + K = v.K.copy() + K[:2] *= s + return replace(v, K=K, width=width, height=round(v.height * s)) + rv, tv = [small(v) for v in rv], [small(v) for v in tv] + assert [v.pose.timestamp for v in rv] == [v.pose.timestamp for v in tv] + n = len(rv) + dev = torch.device("cuda") + params = _to_params(g, dev) + color = ColorCorrection.load(model_dir / "color_correction.pt", 6, len(real.all), dev) + adj = None + if (model_dir / "camera_adjust.pt").exists(): + adj = CameraAdjust(6).to(dev) + adj.load_state_dict(torch.load(model_dir / "camera_adjust.pt", weights_only=True)) + sky = SkyCube.from_state(torch.load(model_dir / "sky.pt", weights_only=True)) if (model_dir / "sky.pt").exists() else None + + frames = [] + w, h = rv[0].width, rv[0].height + K = rv[0].K + for i, v in enumerate(rv + tv): + with torch.no_grad(): + img, alpha, info = render(params, v, g.sh_degree, cam_adjust=adj, cam_grad=False) + img = composite_sky(img, alpha, sky, v, info["viewmat"]) + img = color(img, v.cam_id, rv[i % n].idx).clamp(0, 1) # 변형 프레임도 같은 시각 실제 프레임의 게인 + Image.fromarray((img[0].cpu().numpy() * 255 + 0.5).astype(np.uint8)).save(out / "renders" / f"{i:05d}.png") + Image.fromarray((alpha[0, ..., 0].clamp(0, 1).cpu().numpy() * 255 + 0.5).astype(np.uint8)).save( + out / "opacity" / f"{i:05d}.png") + w2c = info["viewmat"][0].double().cpu().numpy() if info["viewmat"].dim() == 3 else info["viewmat"].double().cpu().numpy() + c2w = np.linalg.inv(w2c) @ OPENCV_TO_OPENGL + fr = rv[i % n].pose + name = f"images/{i % n:05d}.jpg" + if i < n: # 참조 = 원래 자세의 실제 사진 + Image.open(ds.root / fr.filename).convert("RGB").resize((w, h), Image.BICUBIC).save(out / name, quality=95) + frames.append({"file_path": name, "transform_matrix": c2w.tolist(), "timestamp": int(fr.timestamp), + "original_filename": fr.filename, "role": "reference" if i < n else "target"}) + tr = {"camera_model": "OPENCV", "w": int(w), "h": int(h), "fl_x": float(K[0, 0]), "fl_y": float(K[1, 1]), + "cx": float(K[0, 2]), "cy": float(K[1, 2]), "k1": 0.0, "k2": 0.0, "p1": 0.0, "p2": 0.0, "frames": frames} + (out / "transforms.json").write_text(json.dumps(tr, indent=1)) + (out / "selected_indices.json").write_text(json.dumps(list(range(n)))) + (out / "target_indices.json").write_text(json.dumps(list(range(n, 2 * n)))) + (out / "caption.txt").write_text(caption) + sid = f"{scene_name}_{model_dir.name}_{cam}_{variant['name']}{tag}" # tag: 한 구간을 나눈 조각 (artifixer_run.prep_scene) + split = {"test": {sid: {"scene_id": sid, "transforms_path": "transforms.json", "image_root": ".", + "render_dir": "renders", "opacity_dir": "opacity", + "selected_indices_path": "selected_indices.json", "target_indices_path": "target_indices.json", + "prompt_path": "caption.h5", "metric_scale": 1.0, + "camera_scale": CAMERA_CONDITIONING_SCALE, "has_gt": False}}} + (out / "split.json").write_text(json.dumps(split, indent=1)) + meta = {"scene": scene_name, "model": str(model_dir), "cam": cam, "variant": variant, "frames": n, + "size": [int(w), int(h)], "scene_id": sid} + (out / "prep.json").write_text(json.dumps(meta, indent=1)) + log(f" {sid}: 참조 {n}장 + 고칠 프레임 {n}장 ({w}×{h}) → {out}") + return meta diff --git a/src/nusim/nusim/aug/artifixer_run.py b/src/nusim/nusim/aug/artifixer_run.py new file mode 100644 index 0000000000000000000000000000000000000000..cbfa3055275509bff738e204448112bec6e7922a --- /dev/null +++ b/src/nusim/nusim/aug/artifixer_run.py @@ -0,0 +1,257 @@ +"""ArtiFixer + 차선 후처리 증강을 씬 단위로 끝까지 (nusim aug artifixer) — 설정 configs/aug/artifixer.json. + +씬 하나, 카메라 하나마다: + 1. prep 구간 모델(outputs/gs_day/<씬>/seg) × 리그 변형(날씨 없는 9종)마다 ArtiFixer 입력 + (nusim.aug.artifixer_prep.prepare — 구간이 '맡는' 프레임만, nusim.aug.map_aug.assign_front_frames) + → outputs/artifixer/<씬>_/<구간>_<변형>/ + 합친 split.json + 2. infer artifixer 환경에서 model_eval.run_inference 한 번 (모델 로드 한 번에 입력 전부) → <같은 곳>/run + 3. collect 고친 프레임 중 키프레임 → outputs/aug/aug_artfixer/<씬>/<변형>//<원본 파일명>.jpg + meta.json + 4. lanefix 노면을 실제 사진의 평면 호모그래피로 (nusim.aug.lane_fix, road 모드) + → outputs/aug/aug_artfixer/<씬>_lanefix/<변형>// + meta.json + 5. 중간 파일 정리 (렌더 · 불투명도 · 추론 프레임, 씬당 약 2.8 GB) — keep_intermediate 로 남길 수 있다 +끝난 표시는 <씬>_lanefix/done.json (카메라별). 있으면 건너뛰므로 끊었다 다시 돌려도 된다. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import time +from dataclasses import asdict +from pathlib import Path + +from nusim.config import configs_dir +from nusim.paths import output_dir, workspace_root + + +def artifixer_config() -> dict: + return json.loads((configs_dir() / "aug" / "artifixer.json").read_text()) + + +def _variants() -> list[dict]: + from nusim.aug.rig_aug import load_variants + + return [v for v in load_variants(configs_dir() / "aug" / "rig_variants.json") if not v.get("weather")] + + +def done_cams(scene: str) -> set[str]: + p = output_dir() / "aug" / "aug_artfixer" / f"{scene}_lanefix" / "done.json" + return set(json.loads(p.read_text()).get("cams", {})) if p.exists() else set() + + +def finished_scenes(root: Path | None = None) -> list[str]: + """학습 · 기존 증강(aug map)까지 끝난 씬 — 기존 증강이 끝난 순서(= 도로 커버리지 우선 학습 순서)대로.""" + n_var = len(_variants()) + base = output_dir() / "aug" / "map" + out = [] + for d in base.glob("scene-*"): + metas = list(d.glob("*/meta.json")) + if len(metas) >= n_var: + out.append((max(m.stat().st_mtime for m in metas), d.name)) + return [s for _, s in sorted(out)] + + +def prep_scene(scene: str, cam: str, cfg: dict, root: Path, log=print) -> Path: + from nusim.aug.artifixer_prep import prepare + from nusim.aug.map_aug import assign_front_frames + from nusim.data.index import load_scene + from nusim.recon.batch import segment_dirs + + base = output_dir() / "artifixer" / f"{scene}_{cam}" + segs = segment_dirs(root, scene) + if not segs: + raise RuntimeError(f"{scene}: 학습된 구간 모델이 없음 ({root})") + sc = load_scene(scene) + owned = assign_front_frames(sc, [s for s, _ in segs]) + cap = workspace_root() / cfg["caption_h5"] + split = {"test": {}} + for (info, d), fr in zip(segs, owned): + # 구간이 맡는 시각 — 정면 카메라 시각 기준이라 다른 카메라는 구간 모델의 학습 뷰 전부 + ts = {f.timestamp for f in fr} if cam == "CAM_FRONT" else None + # 8 GB: 입력이 길면 ArtiFixer 가 메모리 부족 — 60 m 보다 짧아 한 구간으로 학습된 씬(약 230프레임)이 그랬다. + # 연속한 조각으로 나눠 따로 넣는다 (조각 경계에서 시간 일관성은 끊긴다) + chunks = [ts] + mx = cfg.get("max_frames") + if ts is not None and mx and len(ts) > mx: + order, n = sorted(ts), -(-len(ts) // mx) + chunks = [set(order[j * len(order) // n:(j + 1) * len(order) // n]) for j in range(n)] + for c, cts in enumerate(chunks): + tag = f"_c{c}" if len(chunks) > 1 else "" + for v in _variants(): + out = base / f"{d.name}_{v['name']}{tag}" + meta = prepare(scene, d, v, out, cam=cam, width=cfg["width"], timestamps=cts, tag=tag, log=log) + shutil.copy(cap, out / "caption.h5") + ent = json.loads((out / "split.json").read_text())["test"][meta["scene_id"]] + for k in ("transforms_path", "image_root", "render_dir", "opacity_dir", "selected_indices_path", + "target_indices_path", "prompt_path"): + ent[k] = str((out / ent[k]).resolve()) + split["test"][meta["scene_id"]] = {**ent, "prep_dir": str(out.resolve())} + (base / "split.json").write_text(json.dumps(split, indent=1)) + log(f" prep: 입력 {len(split['test'])}개 → {base / 'split.json'}") + return base + + +def _collected(scene: str, cam: str, base: Path) -> dict[str, list] | None: + """split.json 의 변형마다 수집된 ArtiFixer 결과(meta.json 의 이 카메라 표본) — 하나라도 없으면 None.""" + sp = base / "split.json" + if not sp.exists(): + return None + want = {json.loads((Path(e["prep_dir"]) / "prep.json").read_text())["variant"]["name"] + for e in json.loads(sp.read_text())["test"].values()} + root = output_dir() / "aug" / "aug_artfixer" / scene + out = {} + for var in want: + m = root / var / "meta.json" + smp = [x for x in json.loads(m.read_text())["samples"] if x["filename"].startswith(cam + "/")] if m.exists() else [] + if not smp: + return None + out[var] = smp + return out + + +def _inferred(base: Path) -> bool: + """split.json 의 입력마다 ArtiFixer 결과(pred)가 있나 — 지금 설정(max_frames)으로 만든 split 이어야 한다.""" + sp = base / "split.json" + if not sp.exists(): + return False + sids = json.loads(sp.read_text())["test"] + return bool(sids) and all(next((base / "run").glob(f"*/*/{sid}/frames/batch_0000/pred"), None) for sid in sids) + + +def infer(base: Path, cfg: dict, log=print) -> Path: + """artifixer 환경 하위 프로세스 — vkgs 환경과 torch 버전이 달라 같은 프로세스에서 못 돌린다.""" + repo = workspace_root() / cfg["repo"] + run = base / "run" + env = {**os.environ, "PYTHONPATH": str(repo / "thirdparty" / "3DGRUT-ArtiFixer"), + "PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"} + cmd = [os.path.expanduser(cfg["python"]), "-m", "model_eval.run_inference", "--evalset", "reconstructed_colmap", + "--checkpoint_pt", str(workspace_root() / cfg["checkpoint"]), "--model_id", cfg["model_id"], + "--save_dir", str(run), "--split_path", str(base / "split.json"), *cfg["infer_args"]] + t = time.time() + with open(base / "run.log", "w") as f: + r = subprocess.run(cmd, cwd=repo, env=env, stdout=f, stderr=subprocess.STDOUT) + if r.returncode: + raise RuntimeError(f"ArtiFixer 추론 실패 (코드 {r.returncode}) — {base / 'run.log'}") + log(f" infer: {(time.time() - t) / 60:.1f}분 → {run}") + return run + + +def collect(scene: str, cam: str, base: Path, log=print) -> dict[str, list]: + from PIL import Image + + split = json.loads((base / "split.json").read_text())["test"] + dst_root = output_dir() / "aug" / "aug_artfixer" / scene + per_var: dict[str, list] = {} + size = None + for sid, ent in split.items(): + prep_dir = Path(ent["prep_dir"]) + pm = json.loads((prep_dir / "prep.json").read_text()) + tr = json.loads((prep_dir / "transforms.json").read_text()) + pred = next((base / "run").glob(f"*/*/{sid}/frames/batch_0000/pred"), None) + if pred is None: + raise RuntimeError(f"{sid}: ArtiFixer 결과 없음 — 추론이 중간에 끊겼다") + var = pm["variant"]["name"] + out = dst_root / var / cam + out.mkdir(parents=True, exist_ok=True) + size = Image.open(prep_dir / "renders/00000.png").size # ArtiFixer 는 16 배수로 바꿔 내므로 렌더 크기로 되돌린다 + for i, f in enumerate(tr["frames"]): + if f["role"] != "target" or not f["original_filename"].startswith("samples/"): # 키프레임만 + continue + src = pred / f"{i:05d}.png" + if not src.exists(): + continue + name = Path(f["original_filename"]).name + Image.open(src).convert("RGB").resize(size, Image.BICUBIC).save(out / name, quality=95) + per_var.setdefault(var, []).append({"segment": Path(pm["model"]).name, "original_filename": f["original_filename"], + "filename": f"{cam}/{name}", "timestamp": f["timestamp"]}) + for var, samples in per_var.items(): + p = dst_root / var / "meta.json" + old = json.loads(p.read_text()) if p.exists() else {} + keep = [s for s in old.get("samples", []) if not s["filename"].startswith(cam + "/")] + (dst_root / var / "meta.json").write_text(json.dumps({ + "scene": scene, "variant": var, "cams": sorted(set(old.get("cams", [])) | {cam}), + "method": "ArtiFixer 1.3B (nv-tlabs/artifixer) on VKGS-Sim 3DGS renders", + "size": list(size), "keyframes_only": True, + "samples": sorted(keep + samples, key=lambda s: (s["filename"].split("/")[0], s["timestamp"]))}, indent=1)) + log(f" collect: {sum(map(len, per_var.values()))}장 → {dst_root}") + return per_var + + +def lanefix(scene: str, cam: str, base: Path, log=print) -> int: + from scipy.spatial import cKDTree + + from nusim.aug.lane_fix import LaneFixConfig, Segmenter, fix_prep_dir, map_points + from nusim.data.index import load_scene + + lcfg = LaneFixConfig() + sc = load_scene(scene) + tree = cKDTree(map_points(sc, lcfg)) + seg = Segmenter() + src_root = output_dir() / "aug" / "aug_artfixer" / scene + dst_root = output_dir() / "aug" / "aug_artfixer" / f"{scene}_lanefix" + n = 0 + preps = sorted({Path(e["prep_dir"]) for e in json.loads((base / "split.json").read_text())["test"].values()}) + for prep in preps: # 구간 없는 씬은 scene-XXXX_<변형> — seg* 만 보면 0장이 된다 + var = json.loads((prep / "prep.json").read_text())["variant"]["name"] + n += fix_prep_dir(prep, src_root / var, dst_root / var, seg, sc, tree, lcfg) + for var_dir in src_root.iterdir(): + if not (var_dir / "meta.json").exists() or not (dst_root / var_dir.name).exists(): + continue + m = json.loads((var_dir / "meta.json").read_text()) + m["method"] += " + 노면 평면 호모그래피 차선 후처리 (nusim.aug.lane_fix)" + m["lanefix"] = asdict(lcfg) + (dst_root / var_dir.name / "meta.json").write_text(json.dumps(m, indent=1, ensure_ascii=False)) + del seg + import torch + + torch.cuda.empty_cache() + log(f" lanefix: {n}장 → {dst_root}") + return n + + +def cleanup(base: Path) -> None: + """무거운 중간 파일만 지운다 — prep.json · transforms.json · split.json 은 남겨 다시 후처리할 수 있게.""" + shutil.rmtree(base / "run", ignore_errors=True) + for prep in (d for d in base.iterdir() if d.is_dir() and d.name != "run"): # 구간 없는 씬은 scene-XXXX_<변형> + for d in ("images", "renders", "opacity"): + shutil.rmtree(prep / d, ignore_errors=True) + + +def aug_scene(scene: str, cfg: dict | None = None, root: Path | None = None, cams=None, force: bool = False, + log=print) -> dict: + cfg = cfg or artifixer_config() + root = root or output_dir() / "gs_day" + res = {} + for cam in cams or cfg["cams"]: + if cam in done_cams(scene) and not force: + log(f" {cam}: 이미 끝남 — 건너뜀") + continue + t = time.time() + base = output_dir() / "artifixer" / f"{scene}_{cam}" + per_var = None if force else _collected(scene, cam, base) + if per_var: # 추론 · 수집까지 끝나고 차선 후처리에서 실패한 씬 — 20분짜리 추론을 다시 돌리지 않는다 + log(f" {cam}: ArtiFixer 결과 {sum(map(len, per_var.values()))}장 있음 — 차선 후처리부터") + elif not force and _inferred(base): + log(f" {cam}: 추론 결과 있음 — 수집부터") + else: + base = prep_scene(scene, cam, cfg, root, log=log) + import torch + + torch.cuda.empty_cache() # ArtiFixer 가 8 GB 거의 전부를 쓴다 + infer(base, cfg, log=log) + if not per_var: + per_var = collect(scene, cam, base, log=log) + n = lanefix(scene, cam, base, log=log) + if not cfg.get("keep_intermediate"): + cleanup(base) + p = output_dir() / "aug" / "aug_artfixer" / f"{scene}_lanefix" / "done.json" + p.parent.mkdir(parents=True, exist_ok=True) + d = json.loads(p.read_text()) if p.exists() else {"scene": scene, "cams": {}} + d["cams"][cam] = {"images": n, "variants": sorted(per_var), "minutes": round((time.time() - t) / 60, 1), + "finished": time.strftime("%Y-%m-%d %H:%M")} + p.write_text(json.dumps(d, indent=1)) + res[cam] = d["cams"][cam] + log(f" {cam}: 완료 {n}장, {d['cams'][cam]['minutes']}분") + return res diff --git a/src/nusim/nusim/aug/lane_check.py b/src/nusim/nusim/aug/lane_check.py new file mode 100644 index 0000000000000000000000000000000000000000..1e306259f0c0127d0368631f4a349557bfdc04ba --- /dev/null +++ b/src/nusim/nusim/aug/lane_check.py @@ -0,0 +1,145 @@ +"""증강 영상의 차선 보임 검사 (nusim aug lanecheck) — 차선 검출기 없이, nuScenes 맵의 차선으로 잰다. + +맵의 lane_divider · road_divider 폴리라인을 지면(자차 높이)에 놓고 카메라에 투영한 뒤, 표본점마다 선에 수직인 +밝기 단면을 본다. 단면 가운데 창(±search_m, 맵 · 포즈 오차 몫) 안에 주변(중앙값)보다 contrast 이상 밝은 능선이 있으면 +'보임'. 실제 사진 · 원래 위치 렌더 · 변형 렌더를 같은 표본점으로 재므로, 차이가 곧 재구성 · 시점 이동의 손실이다. +능선이 둘 이상(사이에 골)이면 '이중선' — 시점 이동에서 노면 기하가 틀려 차선이 갈라진 것. + +표본점: 카메라 앞 near_m~far_m, 0.5 m 간격, 영상 안. 사진에서 차량이 가린 점은 사진에서도 안 보이므로 +보임 비율은 사진 대비로 읽는다 (recall_vs_photo = 렌더도 보이고 사진도 보인 점 / 사진에서 보인 점). +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np + + +@dataclass +class LaneCheckConfig: + near_m: float = 5.0 + far_m: float = 25.0 + step_m: float = 0.5 + profile_m: float = 1.2 # 단면 반폭 (지면 m) + search_m: float = 0.35 # 능선을 찾는 가운데 창 반폭 + contrast: float = 0.06 # 능선 − 주변 중앙값 (0~1 밝기) + cams: tuple = ("CAM_FRONT",) + layers: tuple = ("lane_divider", "road_divider") + + +def _gray(path: Path) -> np.ndarray: + from PIL import Image + + return np.asarray(Image.open(path).convert("L"), np.float32) / 255 + + +def map_lines(scene, margin: float = 40.0) -> list[np.ndarray]: + from nusim.maps.expansion import BBox, MapExpansion + from nusim.paths import dataset + + ego = np.array([f.ego_translation for f in scene.frames["CAM_FRONT"]]) + box = BBox(ego[:, 0].min() - margin, ego[:, 1].min() - margin, ego[:, 0].max() + margin, ego[:, 1].max() + margin) + feats = MapExpansion(scene.location, dataset()).features(box) + out = [] + for layer in LaneCheckConfig.layers: + for pl in feats.get(layer, []): + pl = np.asarray(pl, np.float64)[:, :2] + d = np.r_[0, np.cumsum(np.linalg.norm(np.diff(pl, axis=0), axis=1))] + t = np.arange(0, d[-1], 0.5) + if len(t) >= 2: + out.append(np.c_[np.interp(t, d, pl[:, 0]), np.interp(t, d, pl[:, 1])]) + return out + + +def sample_points(pose, lines: list[np.ndarray], ground_z: float, cfg: LaneCheckConfig): + """(u, v, 단면 방향 (du, dv) 픽셀/m) — 지면 위 차선 표본점 중 보이는 것.""" + w2c, K = pose.w2c, pose.K + out = [] + for pl in lines: + tang = np.gradient(pl, axis=0) + tang /= np.linalg.norm(tang, axis=1, keepdims=True) + 1e-9 + nrm = np.c_[-tang[:, 1], tang[:, 0]] + P = np.c_[pl, np.full(len(pl), ground_z), np.ones(len(pl))] + Pn = np.c_[pl + 0.1 * nrm, np.full(len(pl), ground_z), np.ones(len(pl))] + c, cn = (w2c @ P.T).T, (w2c @ Pn.T).T + ok = (c[:, 2] > cfg.near_m) & (c[:, 2] < cfg.far_m) + for i in np.nonzero(ok)[0]: + uv = K @ c[i, :3] + uvn = K @ cn[i, :3] + u, v = uv[:2] / uv[2] + un, vn = uvn[:2] / uvn[2] + if 2 <= u < pose.width - 2 and 2 <= v < pose.height - 2: + out.append((u, v, (un - u) / 0.1, (vn - v) / 0.1)) + return out + + +def ridge(img: np.ndarray, u, v, du, dv, cfg: LaneCheckConfig) -> tuple[bool, int]: + """(가운데 창에 능선이 있나, 창 안 능선 수).""" + s = np.linspace(-cfg.profile_m, cfg.profile_m, 49) + x, y = u + s * du, v + s * dv + h, w = img.shape + keep = (x >= 0) & (x < w - 1) & (y >= 0) & (y < h - 1) + if keep.sum() < 30: + return False, 0 + xi, yi = np.clip(x, 0, w - 1).astype(int), np.clip(y, 0, h - 1).astype(int) + prof = img[yi, xi] + base = np.median(prof[keep]) + mid = keep & (np.abs(s) <= cfg.search_m) + lift = prof - base + hit = mid & (lift > cfg.contrast) + if not hit.any(): + return False, 0 + runs = np.diff(np.r_[0, hit.astype(int), 0]) + return True, int((runs == 1).sum()) + + +def check_scene(scene_name: str, sources: dict[str, Path], variants: dict[str, dict], cfg: LaneCheckConfig | None = None, + keyframes: bool = True) -> dict: + """sources: 이름 → 영상 디렉터리 (//<원본 파일명>), 'photo' 는 데이터셋 원본. variants: 이름 → 리그 변형 + (그 소스의 카메라 포즈). 결과: 소스마다 표본점 수 · 보임 비율 · 사진 대비 재현율 · 이중선 비율.""" + from nusim.aug.rig_aug import transform_calib + from nusim.data.index import load_scene + from nusim.data.rig import camera_pose + from nusim.paths import dataset + + cfg = cfg or LaneCheckConfig() + scene = load_scene(scene_name) + ds = dataset() + lines = map_lines(scene) + res = {} + for name, d in sources.items(): + var = variants.get(name, {}) + st = {"points": 0, "visible": 0, "double": 0, "photo_visible": 0, "both": 0} + for cam in cfg.cams: + calib = transform_calib(scene.calib[cam], var.get("shift_m", (0, 0, 0)), var.get("rot_deg", (0, 0, 0))) + calib0 = scene.calib[cam] + for fr in scene.frames[cam]: + if keyframes and not fr.is_keyframe: + continue + p = (ds.root / fr.filename) if d is None else d / cam / Path(fr.filename).name + if not p.exists(): + continue + img = _gray(p) + pose = camera_pose(fr, calib, cam) + gz = fr.ego_translation[2] + pts = sample_points(pose, lines, gz, cfg) + photo = None + if d is not None and not var: # 같은 포즈의 사진과 점마다 짝지어 재현율 + photo = _gray(ds.root / fr.filename) + for u, v, du, dv in pts: + vis, n = ridge(img, u, v, du, dv, cfg) + st["points"] += 1 + st["visible"] += vis + st["double"] += n >= 2 + if photo is not None: + pv, _ = ridge(photo, u, v, du, dv, cfg) + st["photo_visible"] += pv + st["both"] += pv and vis + n = max(st["points"], 1) + res[name] = {**st, "visible_rate": st["visible"] / n, "double_rate": st["double"] / max(st["visible"], 1)} + if st["photo_visible"]: + res[name]["recall_vs_photo"] = st["both"] / st["photo_visible"] + return res diff --git a/src/nusim/nusim/aug/lane_fix.py b/src/nusim/nusim/aug/lane_fix.py new file mode 100644 index 0000000000000000000000000000000000000000..5b66203085f0c910005abfac8411eb00d9def5c2 --- /dev/null +++ b/src/nusim/nusim/aug/lane_fix.py @@ -0,0 +1,219 @@ +"""차선 후처리 — ArtiFixer 증강 영상의 끊기고 흐린 차선을 실제 사진의 차선으로 바꾼다 (scripts/artifixer_lanefix.py). + +시점 변형은 같은 시각 · 같은 자차에서 카메라만 옮긴 것이다. 노면을 평면(자차 좌표 z = 0)으로 보면, 변형 카메라의 픽셀마다 +광선 → 노면 교점 → 원래 카메라로 투영해 **실제 사진**(1600×900)을 그 자리에 가져올 수 있다 (평면 호모그래피 — 평평한 노면이면 +기하가 정확하다). 그중 nuScenes HD 맵의 lane_divider · road_divider · ped_crossing · stop_line 주변(±band_m)만 바꾼다: +맵은 '차선이 어디 있나' 만 주고, 차선의 모양(점선 간격 · 폭 · 색)은 실제 사진에서 온다 (맵에는 점선 간격 · 폭이 없고 +영상과 10~30 cm 어긋나서, 맵 선을 직접 그리면 실제와 안 맞는다). + +빼는 곳: 노면 교점이 max_dist_m 보다 먼 곳(작은 각도 오차가 크게 번진다), 원래 카메라 영상 밖, 실제 사진에서 차량 · 사람이 +가린 곳 (SegFormer-B5 Cityscapes — nusim.recon.sky 와 같은 모델), 실제 사진에서 노면 · 보도가 아닌 곳. +합성: 띠 안 노면(차선 제외)의 평균 · 표준편차로 실제 사진 색을 ArtiFixer 영상 쪽으로 맞추고, 가장자리를 흐려 섞는다. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + +OPENGL_TO_OPENCV = np.diag([1.0, -1.0, -1.0, 1.0]) +MOVERS = ("car", "truck", "bus", "train", "motorcycle", "bicycle", "person", "rider") +GROUND = ("road", "sidewalk") + + +@dataclass +class LaneFixConfig: + band_m: float = 0.6 # 맵 선 둘레 바꿀 띠 반폭 + max_dist_m: float = 35.0 # 이보다 먼 노면은 그대로 (호모그래피 오차가 커진다) + feather_px: float = 12.0 # 합성 가장자리 섞는 폭 (영역 안쪽으로, 출력 해상도 픽셀) + contrast: float = 0.75 # 실제 사진 노면 대비를 ArtiFixer 쪽으로 맞추는 정도 (1 = 완전히) + mover_dilate_px: int = 9 # 실제 사진 차량 마스크 팽창 (원본 해상도) + layers: tuple = ("lane_divider", "road_divider", "ped_crossing", "stop_line") + mode: str = "road" # road = 노면 전체를 실제 사진으로 (권장) · band = 맵 차선 띠만 + + +def map_points(scene, cfg: LaneFixConfig, step: float = 0.1, margin: float = 60.0) -> np.ndarray: + """맵 차선 요소를 전역 xy 점들로 (선은 0.1 m 간격, 다각형은 테두리 + 내부 격자).""" + from matplotlib.path import Path as MPath + + from nusim.maps.expansion import BBox, MapExpansion + from nusim.paths import dataset + + ego = np.array([f.ego_translation for f in scene.frames["CAM_FRONT"]]) + box = BBox(ego[:, 0].min() - margin, ego[:, 1].min() - margin, ego[:, 0].max() + margin, ego[:, 1].max() + margin) + feats = MapExpansion(scene.location, dataset()).features(box) + pts = [] + for layer in cfg.layers: + for pl in feats.get(layer, []): + pl = np.asarray(pl, np.float64)[:, :2] + d = np.r_[0, np.cumsum(np.linalg.norm(np.diff(pl, axis=0), axis=1))] + if d[-1] <= 0: + continue + t = np.arange(0, d[-1], step) + pts.append(np.c_[np.interp(t, d, pl[:, 0]), np.interp(t, d, pl[:, 1])]) + if layer in ("ped_crossing", "stop_line") and len(pl) >= 3: # 다각형 내부도 (횡단보도 줄무늬) + lo, hi = pl.min(0), pl.max(0) + gx, gy = np.meshgrid(np.arange(lo[0], hi[0], 0.3), np.arange(lo[1], hi[1], 0.3)) + g = np.c_[gx.ravel(), gy.ravel()] + pts.append(g[MPath(pl).contains_points(g)]) + return np.concatenate(pts) if pts else np.zeros((0, 2)) + + +class Segmenter: + def __init__(self): + import torch + from transformers import SegformerForSemanticSegmentation + + from nusim.recon.sky import MODEL + + self.torch = torch + self.model = SegformerForSemanticSegmentation.from_pretrained(MODEL).half().cuda().eval() + ids = {v.lower(): int(k) for k, v in self.model.config.id2label.items()} + self.movers = [ids[k] for k in MOVERS if k in ids] + self.ground = [ids[k] for k in GROUND if k in ids] + self.mean = torch.tensor([0.485, 0.456, 0.406], device="cuda")[:, None, None] + self.std = torch.tensor([0.229, 0.224, 0.225], device="cuda")[:, None, None] + + def __call__(self, im: np.ndarray) -> np.ndarray: + """(H,W,3) uint8 → 클래스 번호 (H,W).""" + import torch.nn.functional as F + + t = self.torch.as_tensor(im, device="cuda").permute(2, 0, 1).float() / 255 + h, w = im.shape[:2] + t = F.interpolate(t[None], size=(576, 1024), mode="bilinear", align_corners=False) + with self.torch.no_grad(): + lg = self.model(pixel_values=((t - self.mean) / self.std).half()).logits + lg = F.interpolate(lg.float(), size=(h, w), mode="bilinear", align_corners=False)[0] + return lg.argmax(0).cpu().numpy() + + +def ground_lookup(c2w_v: np.ndarray, Kv: np.ndarray, size_v, plane_n: np.ndarray, plane_p: np.ndarray): + """변형 카메라 픽셀마다 노면 교점 (H,W,3) 과 거리 (H,W). 노면에 안 닿으면 nan.""" + w, h = size_v + u, v = np.meshgrid(np.arange(w) + 0.5, np.arange(h) + 0.5) + d_cam = np.stack([(u - Kv[0, 2]) / Kv[0, 0], (v - Kv[1, 2]) / Kv[1, 1], np.ones_like(u)], -1) + R, o = c2w_v[:3, :3], c2w_v[:3, 3] + d = d_cam @ R.T + denom = d @ plane_n + t = ((plane_p - o) @ plane_n) / np.where(np.abs(denom) < 1e-9, np.nan, denom) + t = np.where(t > 0, t, np.nan) + X = o + d * t[..., None] + dist = t * np.linalg.norm(d_cam, axis=-1) + return X, dist + + +def project(X: np.ndarray, c2w: np.ndarray, K: np.ndarray): + w2c = np.linalg.inv(c2w) + Xc = X @ w2c[:3, :3].T + w2c[:3, 3] + z = Xc[..., 2] + uv = (Xc[..., :2] / z[..., None]) * np.array([K[0, 0], K[1, 1]]) + np.array([K[0, 2], K[1, 2]]) + return uv, z + + +def bilinear(img: np.ndarray, uv: np.ndarray) -> np.ndarray: + import cv2 + + m = uv.astype(np.float32) + return cv2.remap(img, m[..., 0] - 0.5, m[..., 1] - 0.5, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE) + + +def fix_image(art: np.ndarray, photo: np.ndarray, seg: np.ndarray, seg_ids: tuple, c2w_v, c2w_o, Kv, Ko, + plane_n, plane_p, origin_xy, tree, cfg: LaneFixConfig) -> tuple[np.ndarray, np.ndarray]: + """(후처리 영상, 바꾼 가중치 α) — art (h,w,3) 출력 해상도, photo (H,W,3) 원본 해상도.""" + import cv2 + + h, w = art.shape[:2] + X, dist = ground_lookup(c2w_v, Kv, (w, h), plane_n, plane_p) + ok = np.isfinite(dist) & (dist < cfg.max_dist_m) + Xs = np.where(ok[..., None], X, 0) + if cfg.mode == "band": + dmap, _ = tree.query((Xs[..., :2] + origin_xy).reshape(-1, 2), distance_upper_bound=cfg.band_m) + ok &= np.isfinite(dmap.reshape(h, w)) + uv, z = project(Xs, c2w_o, Ko) + H, W = photo.shape[:2] + ok &= (z > 0.5) & (uv[..., 0] >= 0) & (uv[..., 0] < W - 1) & (uv[..., 1] >= 0) & (uv[..., 1] < H - 1) + movers, ground = seg_ids + bad = np.isin(seg, movers).astype(np.uint8) + bad = cv2.dilate(bad, np.ones((cfg.mover_dilate_px, cfg.mover_dilate_px), np.uint8)) > 0 + good = np.isin(seg, ground if cfg.mode == "band" else ground[:1]) & ~bad # road 모드: 차도만 (보도는 15 cm 높아 평면이 아니다) + uvc = np.clip(np.nan_to_num(uv), 0, [W - 1, H - 1]) + ok &= bilinear(good.astype(np.float32), uvc) > 0.99 + if ok.sum() < 50: + return art, np.zeros((h, w), np.float32) + warped = bilinear(photo.astype(np.float32), uvc) + # 색 맞춤: 띠 안 노면(차선 페인트 = 밝은 상위 20% 제외)에서 채널별 평균 · 표준편차 + lum_w, lum_a = warped.mean(-1), art.astype(np.float32).mean(-1) + road = ok & (lum_w < np.percentile(lum_w[ok], 80)) + out = art.astype(np.float32).copy() + fixed = warped.copy() + for c in range(3): + mw, sw = warped[..., c][road].mean(), warped[..., c][road].std() + 1e-3 + ma, sa = out[..., c][road].mean(), out[..., c][road].std() + 1e-3 + k = cfg.contrast * (sa / sw) + (1 - cfg.contrast) # 대비를 다 맞추면 차선이 흐려진다 — 일부만 + fixed[..., c] = (warped[..., c] - mw) * k + ma + # 영역 경계(원래 카메라 시야 끝 · 연석 · 차량)에서 안쪽으로 feather_px 에 걸쳐 섞는다 + inside = cv2.distanceTransform(ok.astype(np.uint8), cv2.DIST_L2, 5) + a = cv2.GaussianBlur(np.clip(inside / cfg.feather_px, 0, 1).astype(np.float32), (0, 0), 1.5) * ok + out = out * (1 - a[..., None]) + fixed * a[..., None] + _ = lum_a + return np.clip(out, 0, 255).astype(np.uint8), a + + +def plane_of(frame, origin: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """자차 좌표 z = 0 평면 (씬 원점 기준 좌표의 법선 · 한 점).""" + from nusim.data.rig import quat_to_matrix + + R = quat_to_matrix(frame.ego_rotation) + return R[:, 2], np.asarray(frame.ego_translation, np.float64) - origin + + +def fix_prep_dir(prep_dir: Path, art_dir: Path, out_dir: Path, seg: Segmenter, scene, tree, cfg: LaneFixConfig, + keyframes_only: bool = True, debug_dir: Path | None = None) -> int: + """준비 디렉터리 하나(구간 × 변형)의 고칠 프레임마다. art_dir = 그 변형의 ArtiFixer 결과 (/<원본 파일명>.jpg).""" + from PIL import Image + + from nusim.io.ply import read_origin + from nusim.paths import dataset + + pm = json.loads((prep_dir / "prep.json").read_text()) + tr = json.loads((prep_dir / "transforms.json").read_text()) + origin = read_origin(Path(pm["model"]) / "point_cloud.ply") + frames = tr["frames"] + n = len(frames) // 2 + Kv = np.array([[tr["fl_x"], 0, tr["cx"]], [0, tr["fl_y"], tr["cy"]], [0, 0, 1]], np.float64) + by_fn = {f.filename: f for f in scene.frames[pm["cam"]]} + calib = scene.calib[pm["cam"]] + Ko = np.asarray(calib.intrinsic, np.float64) + ds = dataset() + cnt = 0 + for i in range(n): + ref, tgt = frames[i], frames[i + n] + fn = ref["original_filename"] + if keyframes_only and not fn.startswith("samples/"): + continue + src = art_dir / pm["cam"] / Path(fn).name + if not src.exists(): + continue + art = np.asarray(Image.open(src).convert("RGB")) + photo = np.asarray(Image.open(ds.root / fn).convert("RGB")) + s = photo.shape[1] / tr["w"] # 원래 카메라는 원본 해상도로 — 자세는 같고 K 만 키운다 + Ko_s = Kv.copy() + Ko_s[:2] *= s + c2w_v = np.asarray(tgt["transform_matrix"]) @ OPENGL_TO_OPENCV + c2w_o = np.asarray(ref["transform_matrix"]) @ OPENGL_TO_OPENCV + pn, pp = plane_of(by_fn[fn], origin) + lab = seg(photo) + out, a = fix_image(art, photo, lab, (seg.movers, seg.ground), c2w_v, c2w_o, Kv, Ko_s, pn, pp, + origin[:2], tree, cfg) + dst = out_dir / pm["cam"] / Path(fn).name + dst.parent.mkdir(parents=True, exist_ok=True) + Image.fromarray(out).save(dst, quality=95) + if debug_dir is not None: + debug_dir.mkdir(parents=True, exist_ok=True) + Image.fromarray((a * 255).astype(np.uint8)).save(debug_dir / (Path(fn).stem + "_alpha.png")) + cnt += 1 + _ = Ko + return cnt diff --git a/src/nusim/nusim/aug/map_aug.py b/src/nusim/nusim/aug/map_aug.py new file mode 100644 index 0000000000000000000000000000000000000000..c1d6795a9371b443479d9b801edc3fefe2673736 --- /dev/null +++ b/src/nusim/nusim/aug/map_aug.py @@ -0,0 +1,115 @@ +"""맵 전체 시점 변형 증강 — 일괄 재구성(nusim recon batch)한 낮 씬들을 리그 변형으로 다시 찍는다. + +nusim aug rig 은 씬 하나를 모델 하나로 그린다. 맵 일괄 재구성에서는 긴 씬이 구간(seg)으로 나뉘어 있으므로 +(nusim.recon.batch.plan_segments), CAM_FRONT 프레임마다 그 시각을 '맡는' 구간의 모델로 그린다. 맡는 구간은 이웃과 +겹치지 않는다 (겹침 10 m 의 가운데에서 자름) — 프레임마다 정확히 한 모델이고, 각 모델은 자기 학습 구간 안쪽만 그린다. + +결과: ////<원본 파일명>.jpg + meta.json (nusim aug rig 과 같은 형식 + segments·keyframes_only). +같은 모델·설정으로 끝난 변형은 건너뛴다 (meta.json 의 model 목록 비교) — 일괄 학습을 따라가며 여러 번 불러도 된다. +""" + +from __future__ import annotations + +import json +import logging +import shutil +from dataclasses import replace +from pathlib import Path + +LOG = logging.getLogger(__name__) + + +def assign_front_frames(scene, segs: list[dict]) -> list[list]: + """구간마다 맡는 CAM_FRONT 프레임 — 반열린 구간 [own0, own1) (마지막은 끝까지), 씬 시작 기준 마이크로초로 비교.""" + start = min(f.timestamp for fs in scene.frames.values() for f in fs[:1]) + front = scene.frames["CAM_FRONT"] + out = [] + for i, s in enumerate(segs): + lo = -1 if s.get("own0") is None or i == 0 else start + round(s["own0"] * 1e6) + hi = None if s.get("own1") is None or i == len(segs) - 1 else start + round(s["own1"] * 1e6) + out.append([f for f in front if f.timestamp >= lo and (hi is None or f.timestamp < hi)]) + return out + + +def _stamp(d: Path) -> str: + p = d / "point_cloud.ply" + return f"{d}@{int(p.stat().st_mtime)}" + + +def ensure_ibl(scene_name: str, segs: list[tuple[dict, Path]], log=LOG.info) -> None: + """glTF 차량을 다시 넣으려면 씬의 IBL(환경 조명)이 있어야 한다 (_ibl_gltf_args). 없으면 가운데 프레임을 맡는 구간 모델로 굽는다.""" + from nusim.data.index import load_scene + from nusim.paths import output_dir + from nusim.render.ibl import bake_ibl + + out = output_dir() / "ibl" / scene_name + if (out / "env_specular.ktx2").exists(): + return + scene = load_scene(scene_name) + mid = len(scene.frames["CAM_FRONT"]) // 2 + owned = assign_front_frames(scene, [s for s, _ in segs]) + tmid = scene.frames["CAM_FRONT"][mid].timestamp + d = next((d for (s, d), fr in zip(segs, owned) if any(f.timestamp == tmid for f in fr)), segs[0][1]) + # 구간 모델의 색 보정은 그 학습 구간의 뷰 수만큼이다 — 같은 구간으로 자른 씬을 넘긴다 + from nusim.cli.main import _model_data + + sub, _ = _model_data(scene_name, d) + front = sub.frames["CAM_FRONT"] + k = min(range(len(front)), key=lambda i: abs(front[i].timestamp - tmid)) + log(f" IBL 굽기 (모델 {d})") + bake_ibl(sub, d, out, frame=k) + + +def aug_scene(scene_name: str, root: Path, variants: list[dict], out_root: Path, viewer: Path, + keyframes: bool = True, agents: bool = True, log=LOG.info) -> dict: + from nusim.aug.rig_aug import keyframes_only, render_frames, variant_meta + from nusim.cli.main import _ibl_gltf_args + from nusim.data.index import load_scene + from nusim.recon.batch import segment_dirs + + segs = segment_dirs(root, scene_name) + if not segs: + raise FileNotFoundError(f"{root / scene_name} 에 학습된 모델이 없다") + if len(segs) != segs[0][0]["n"]: + raise RuntimeError(f"{scene_name}: 구간 {segs[0][0]['n']}개 중 {len(segs)}개만 학습됨") + if agents: + ensure_ibl(scene_name, segs, log) + extra = _ibl_gltf_args(scene_name, agents=True) if agents else [] + scene = load_scene(scene_name) + owned = assign_front_frames(scene, [s for s, _ in segs]) + base = keyframes_only(scene) if keyframes else scene + models = [_stamp(d) for _, d in segs] + summary = {} + for v in variants: + out = out_root / scene_name / v["name"] + meta_path = out / "meta.json" + if meta_path.exists(): + old = json.loads(meta_path.read_text()) + if old.get("model") == models and old.get("keyframes_only") == keyframes and old.get("variant") == v \ + and old.get("agents") == agents: + summary[v["name"]] = len(old["samples"]) + continue + if out.exists(): + shutil.rmtree(out) + samples, seg_meta, offset = [], [], 0 + for (s, d), fr in zip(segs, owned): + keep = {f.timestamp for f in fr} + front = [f for f in base.frames["CAM_FRONT"] if f.timestamp in keep] + if not front: + continue + sub = replace(base, frames={**base.frames, "CAM_FRONT": front}) + got = render_frames(sub, d, v, out, viewer, extra) + (out / "rig.txt").rename(out / f"rig_seg{s['k']}.txt") + for x in got: + x["frame"] += offset + x["segment"] = s["k"] + offset += len(front) + samples += got + seg_meta.append({"k": s["k"], "own0": s.get("own0"), "own1": s.get("own1"), "model": str(d), + "front_frames": len(front), "images": len(got)}) + meta = variant_meta(scene, v, models, samples) + meta.update(keyframes_only=keyframes, agents=agents, segments=seg_meta) + meta_path.write_text(json.dumps(meta, indent=1, ensure_ascii=False)) + summary[v["name"]] = len(samples) + log(f" {v['name']:<14} {len(samples):,}장 (구간 {len(seg_meta)}개) → {out}") + return summary diff --git a/src/nusim/nusim/aug/rig_aug.py b/src/nusim/nusim/aug/rig_aug.py new file mode 100644 index 0000000000000000000000000000000000000000..af790b485a4824fbbe66fd00462e861b784b4e74 --- /dev/null +++ b/src/nusim/nusim/aug/rig_aug.py @@ -0,0 +1,170 @@ +"""카메라 리그 변형 증강 — 재구성한 낮 장면을 6캠 시점을 바꿔 다시 찍는다. + +같은 주행(같은 시각·같은 자차 궤적)을 카메라 장착 위치·자세만 바꿔 렌더한다: 옆 차선 쪽으로 이동, 더 높은/낮은 +차, 아래로 숙임, 좌우로 돌림 (configs/aug/rig_variants.json). 6캠 전체에 같은 변형을 주므로 리그 기하가 일관된다. + +방법: 씬 인덱스의 카메라 캘리브레이션(cam → ego)만 바꾼 사본으로 뷰어 리그(rig.txt)를 만들고 +(nusim.render.export.write_rig — 학습이 보정한 외부 파라미터도 그대로 적용), vkgs_viewer --offscreen 으로 렌더한다. +결과: outputs/aug/rig/// + /<원본 파일명>.jpg 원본과 같은 이름 — CAM_FRONT 프레임마다 각 카메라의 가장 가까운 장 (rig.txt 와 같은 대응). + 원본 sweep 전부는 아니다 (카메라마다 약 12 Hz 중 CAM_FRONT 시각에 가까운 것만) + meta.json 변형, 바뀐 calibrated_sensor (cam → ego, nuScenes 형식), 장마다 원본 파일·시각·자차 포즈 + rig.txt 렌더에 쓴 리그 +원본 데이터셋에는 쓰지 않는다. +""" + +from __future__ import annotations + +import json +import logging +import shutil +import subprocess +from dataclasses import replace +from pathlib import Path + +import numpy as np + +from nusim.data.rig import matrix_to_quat, quat_to_matrix + +LOG = logging.getLogger(__name__) +RENDER_ATTEMPTS = 3 # 깨진 PNG 가 나오면 구간을 다시 렌더하는 횟수 (render_frames) + + +def _broken_pngs(d: Path) -> list[Path]: + """d 의 PNG 중 끝까지 디코딩되지 않는 것 (zlib 검사합 포함).""" + from PIL import Image + + bad = [] + for p in sorted(d.glob("*.png")): + try: + with Image.open(p) as im: + im.load() + except OSError: + bad.append(p) + return bad + + +def _rx(a: float) -> np.ndarray: + c, s = np.cos(a), np.sin(a) + return np.array([[1, 0, 0], [0, c, -s], [0, s, c]]) + + +def _rz(a: float) -> np.ndarray: + c, s = np.cos(a), np.sin(a) + return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]]) + + +def transform_calib(calib, shift_m=(0, 0, 0), rot_deg=(0, 0, 0)): + """CameraCalib (cam → ego) 에 리그 변형을 준다. + shift_m: ego 좌표 [x 앞, y 왼쪽, z 위] 이동. rot_deg: [roll, pitch, yaw] — + pitch + = 카메라가 아래로 숙인다 (카메라 자신의 가로축), yaw + = 왼쪽으로 (ego z 축, 리그 전체가 같이), + roll = 카메라 광축 둘레. 카메라 좌표는 OpenCV (x 오른쪽, y 아래, z 앞) — rig.py 와 같다.""" + roll, pitch, yaw = np.radians(np.asarray(rot_deg, dtype=np.float64)) + R = quat_to_matrix(calib.rotation) + # 카메라 x 축 둘레 +θ 는 광축(z)을 위(-y)로 든다 → 숙이려면 -pitch + R2 = _rz(yaw) @ R @ _rx(-pitch) @ _rz(roll) + t2 = np.asarray(calib.translation, dtype=np.float64) + np.asarray(shift_m, dtype=np.float64) + return replace(calib, rotation=matrix_to_quat(R2), translation=tuple(float(x) for x in t2)) + + +def load_variants(path: Path, names: list[str] | None = None) -> list[dict]: + vs = [v for v in json.loads(path.read_text())["variants"]] + if names: + missing = set(names) - {v["name"] for v in vs} + if missing: + raise ValueError(f"변형이 없음: {sorted(missing)} (있는 것: {[v['name'] for v in vs]})") + vs = [v for v in vs if v["name"] in names] + return vs + + +def keyframes_only(scene): + """키프레임(2 Hz sample)만 남긴 사본. rig_frames 는 CAM_FRONT 시각에 가장 가까운 장을 고르므로, 모든 카메라를 + 키프레임으로 줄이면 같은 sample 의 6장이 짝지어진다.""" + return replace(scene, frames={c: [f for f in fs if f.is_keyframe] for c, fs in scene.frames.items()}) + + +def render_frames(scene, model_dir: Path, variant: dict, out: Path, viewer: Path, + extra_args: list[str] | None = None, jpeg_quality: int = 95) -> list[dict]: + """scene(구간·키프레임으로 줄인 사본이어도 됨)의 CAM_FRONT 프레임마다 6캠을 리그 변형으로 렌더해 + out//<원본 파일명>.jpg 로 쓴다. 장마다 메타 목록을 돌려준다 (frame 은 이 호출 안의 번호).""" + from PIL import Image + + from nusim.cli.main import _model_objects + from nusim.io.ply import read_origin + from nusim.render.export import load_camera_adjust, rig_frames, write_rig + + vscene = replace(scene, calib={cam: transform_calib(c, variant.get("shift_m", (0, 0, 0)), variant.get("rot_deg", (0, 0, 0))) + for cam, c in scene.calib.items()}) + out.mkdir(parents=True, exist_ok=True) + origin = read_origin(model_dir / "point_cloud.ply") + write_rig(out / "rig.txt", vscene, origin, load_camera_adjust(model_dir), objects=_model_objects(model_dir)) + tmp = out / "_png" + # 데이터셋용: 화면 공간 AA 없음 — 실제 카메라 영상처럼. 하늘: 날씨가 없으면 학습기와 같은 하늘(하늘 가우시안 유지, + # 하늘 큐브맵을 학습한 모델이면 그 큐브맵), 날씨 변형이면 낮 하늘 가우시안을 빼고 시뮬레이션 하늘 + cmd = [str(viewer), str(model_dir / "point_cloud.ply"), "--cams", str(out / "rig.txt"), "--offscreen", + "--out", str(tmp), "--pass", "fxaa=off"] + if (model_dir / "objects" / "objects.json").exists(): # 차량을 함께 학습한 모델 — 차량 에셋도 같은 시점에서 + cmd += ["--objects", str(model_dir / "objects")] + if variant.get("weather"): + cmd += ["--weather", variant["weather"]] + else: + cmd += ["--keep-sky-gaussians"] + if (model_dir / "sky.ktx2").exists(): + cmd += ["--sky", str(model_dir / "sky.ktx2")] + cmd += extra_args or [] + # 렌더는 결정적인데 PNG 가 가끔 깨진다 — 다시 렌더한 것과 1비트 차이 (2026-10-12, scene-0017·0036: ECC 없는 메모리의 + # 비트 뒤집힘으로 본다). zlib 검사합이 잡으므로 PNG 를 다 열어 보고, 깨졌으면 그 구간을 한 번 더 렌더한다 + for attempt in range(RENDER_ATTEMPTS): + if tmp.exists(): + shutil.rmtree(tmp) + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + raise RuntimeError(f"vkgs_viewer 실패:\n{r.stdout[-2000:]}\n{r.stderr[-2000:]}") + bad = _broken_pngs(tmp) + if not bad: + break + LOG.warning(f" 깨진 PNG {len(bad)}장 ({bad[0].name}) — 다시 렌더 ({attempt + 2}/{RENDER_ATTEMPTS})") + else: + raise RuntimeError(f"PNG 가 {RENDER_ATTEMPTS}번 다 깨짐: {bad[:3]}") + samples, done = [], set() + for k, cam, fr in rig_frames(scene): + src = tmp / f"f{k:04d}_{cam}.png" + # rig 는 CAM_FRONT 프레임마다 각 카메라의 가장 가까운 장을 고르므로 같은 장이 두 번 뽑힐 수 있다 — 한 번만 + if not src.exists() or fr.filename in done: + continue + done.add(fr.filename) + dst = out / cam / Path(fr.filename).name + dst.parent.mkdir(parents=True, exist_ok=True) + Image.open(src).convert("RGB").save(dst, quality=jpeg_quality) + samples.append({"frame": k, "cam": cam, "timestamp": fr.timestamp, "is_keyframe": fr.is_keyframe, + "original_filename": fr.filename, "filename": str(dst.relative_to(out)), + "ego_translation": list(fr.ego_translation), "ego_rotation": list(fr.ego_rotation)}) + shutil.rmtree(tmp) + return samples + + +def variant_meta(scene, variant: dict, models, samples: list[dict]) -> dict: + vscene_calib = {cam: transform_calib(c, variant.get("shift_m", (0, 0, 0)), variant.get("rot_deg", (0, 0, 0))) + for cam, c in scene.calib.items()} + return { + "scene": scene.name, "variant": variant, "model": models, + "calibrated_sensor": {cam: {"translation": list(c.translation), "rotation": list(c.rotation), + "camera_intrinsic": [list(r) for r in c.intrinsic]} + for cam, c in vscene_calib.items()}, + "note": "calibrated_sensor = 바뀐 cam → ego (nuScenes 형식, 학습 보정 전). 자차 포즈는 원본 그대로.", + "samples": samples, + } + + +def render_variant(scene_name: str, model_dir: Path, variant: dict, out_root: Path, viewer: Path, + extra_args: list[str] | None = None, jpeg_quality: int = 95, log=LOG.info) -> dict: + """씬 전체를 모델 하나로 (nusim aug rig). 구간 모델로 나눈 씬은 nusim.aug.map_aug.""" + from nusim.data.index import load_scene + + scene = load_scene(scene_name) + out = out_root / scene_name / variant["name"] + samples = render_frames(scene, model_dir, variant, out, viewer, extra_args, jpeg_quality) + meta = variant_meta(scene, variant, str(model_dir), samples) + (out / "meta.json").write_text(json.dumps(meta, indent=1, ensure_ascii=False)) + log(f" {variant['name']:<20} {len(samples):,}장 → {out}") + return meta diff --git a/src/nusim/nusim/aug/translate.py b/src/nusim/nusim/aug/translate.py new file mode 100644 index 0000000000000000000000000000000000000000..5883578f0763bb911a8438b46403ca47f832f2ae --- /dev/null +++ b/src/nusim/nusim/aug/translate.py @@ -0,0 +1,117 @@ +"""밤·비 → 낮 영상 변환 — 낮 씬만으로는 못 덮는 지역(싱가포르 야간·우천 씬)을 재구성할 수 있게. + +모델: CycleGAN-Turbo (img2img-turbo, Parmar et al. 2024, MIT 코드 / SD-Turbo 가중치 연구용) 의 BDD100K 주행 영상 +사전학습 체크포인트 night_to_day, rainy_to_clear. third_party/img2img-turbo, 체크포인트는 cache/models/img2img-turbo +(cache/models/img2img-turbo/fetch.sh 로 병렬 다운로드 — 개당 1.7 GB). + +결과는 원본과 같은 상대 경로로 outputs/aug/translate//samples|sweeps/... 에 쓴다. 재구성은 +`nusim recon train --image-root outputs/aug/translate/` — 이미지만 바뀌고 라이다·포즈는 원본이다. +원본 데이터셋에는 쓰지 않는다. + +알고 쓰자: 프레임마다 따로 변환하므로 시간 깜빡임·카메라 사이 불일치가 있다. 3DGS 는 여러 뷰의 평균을 배우므로 +조금은 견디지만, 결과는 재구성 PSNR·육안으로 확인한다. +""" + +from __future__ import annotations + +import logging +import sys +import time +from pathlib import Path + +import numpy as np + +LOG = logging.getLogger(__name__) +ROOT = Path(__file__).resolve().parents[4] +CKPT_DIR = ROOT / "cache" / "models" / "img2img-turbo" +MODELS = {"night_to_day": "night2day.pkl", "rainy_to_clear": "rainy2clear.pkl"} + + +def load_model(name: str): + import torch + + if name not in MODELS: + raise ValueError(f"모르는 변환 '{name}' (있는 것: {list(MODELS)})") + if not (CKPT_DIR / MODELS[name]).exists(): + raise FileNotFoundError(f"{CKPT_DIR / MODELS[name]} 가 없습니다: bash {CKPT_DIR / 'fetch.sh'} {MODELS[name][:-4]}") + src = str(ROOT / "third_party" / "img2img-turbo" / "src") + if src not in sys.path: + sys.path.append(src) # nusim 보다 뒤 — 그쪽 모듈 이름과 겹치지 않게 + from cyclegan_turbo import CycleGAN_Turbo + + # 원래 코드는 fp32 로 GPU 에 올린 뒤 줄인다 (4.4 GB) — 8 GB GPU 에서 학습과 같이 못 돈다. + # 생성 중 Module.cuda 를 잠시 막아 CPU 에서 만들고, fp16 으로 바꾼 뒤 올린다 (약 2 GB). + # 체크포인트(1.7 GB fp32)도 GPU 로 저장돼 있어 torch.load 가 바로 올린다 → CPU 로 읽게. + real_cuda, real_load = torch.nn.Module.cuda, torch.load + torch.nn.Module.cuda = lambda self, *a, **k: self + torch.load = lambda f, *a, **k: real_load(f, *a, **{**k, "map_location": "cpu", "weights_only": False}) + try: + m = CycleGAN_Turbo(pretrained_name=name, ckpt_folder=str(CKPT_DIR)) + finally: + torch.nn.Module.cuda, torch.load = real_cuda, real_load + m = m.eval().half().cuda() + m.requires_grad_(False) + return m + + +def translate_image(model, img, size: tuple[int, int]): + """PIL 이미지 → 변환한 PIL 이미지 (원래 크기로). size = 모델 입력 (8의 배수).""" + import torch + from PIL import Image + from torchvision import transforms + + x = transforms.ToTensor()(img.resize(size, Image.LANCZOS)) + x = (x * 2 - 1).unsqueeze(0).cuda().half() + with torch.no_grad(): # 전역 grad 설정은 건드리지 않는다 (같은 프로세스의 학습을 망치지 않게) + y = model(x) + out = ((y[0].float().clamp(-1, 1) + 1) * 127.5).round().byte().permute(1, 2, 0).cpu().numpy() + return Image.fromarray(out).resize(img.size, Image.LANCZOS) + + +def translate_scene(scene_name: str, models: list[str], out_root: Path, size=(912, 512), cameras=None, + stride: int = 1, keyframes_only: bool = False, quality: int = 95, log=LOG.info) -> dict: + """씬의 카메라 영상을 models 순서대로 변환 (예: rainy_to_clear 다음 night_to_day). 이미 있는 파일은 건너뛴다.""" + from PIL import Image + + from nusim import CAMERAS + from nusim.data.index import load_scene + from nusim.paths import dataset + + scene, ds = load_scene(scene_name), dataset() + cams = cameras or CAMERAS + frames = [] + for cam in cams: + fr = scene.keyframes(cam) if keyframes_only else scene.frames[cam] + frames += fr[::stride] + tag = "+".join(models) + out = out_root / tag + todo = [f for f in frames if not (out / f.filename).exists()] + log(f" {scene_name}: {len(frames):,}장 중 {len(todo):,}장 변환 ({tag}, 입력 {size[0]}x{size[1]}) → {out}") + if not todo: + return {"n": len(frames), "done": 0, "out": str(out)} + # 모델은 하나씩만 올린다 (fp16 하나에 약 2.2 GB — 8 GB GPU 에서 학습과 같이 돌게). 연쇄 변환은 단계별로: + # 앞 단계 결과를 out/_stage/ 에 두고 다음 단계가 읽는다. + import shutil + + import torch + + t0 = time.time() + src_root = ds.root + for si, name in enumerate(models): + last = si == len(models) - 1 + dst_root = out if last else out / f"_stage{si}_{scene_name}" # 씬마다 따로 (동시에 돌려도 서로 안 지우게) + net = load_model(name) + for i, f in enumerate(todo): + img = Image.open(src_root / f.filename).convert("RGB") # 원본은 읽기만 + img = translate_image(net, img, size) + dst = dst_root / f.filename + dst.parent.mkdir(parents=True, exist_ok=True) + img.save(dst, quality=quality if last else 98) + if i % 200 == 0: + log(f" [{name}] {i:,}/{len(todo):,} {(time.time() - t0) / max(i + si * len(todo), 1):.2f}s/장") + del net + torch.cuda.empty_cache() + if si > 0: + shutil.rmtree(src_root) # 앞 단계 임시 결과 + src_root = dst_root + return {"n": len(frames), "done": len(todo), "out": str(out), "s_per_image": (time.time() - t0) / len(todo)} diff --git a/src/nusim/nusim/cli/__init__.py b/src/nusim/nusim/cli/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f447ac990af380a0f4f969eff39aa2db8b780201 --- /dev/null +++ b/src/nusim/nusim/cli/__init__.py @@ -0,0 +1 @@ +"""커맨드라인 진입점.""" diff --git a/src/nusim/nusim/cli/doctor.py b/src/nusim/nusim/cli/doctor.py new file mode 100644 index 0000000000000000000000000000000000000000..41709aaff1b18776bc5ebf8433bfbbecf4e9ad7f --- /dev/null +++ b/src/nusim/nusim/cli/doctor.py @@ -0,0 +1,255 @@ +"""vkgs-doctor — 컨테이너 환경이 실제로 쓸 수 있는 상태인지 한 번에 점검한다. + +이게 따로 있는 이유: 이 스택은 조용히 반쯤 망가지는 방식이 여럿이다. + - nvidia-container-toolkit 은 있는데 DRIVER_CAPABILITIES 에 graphics 가 빠지면 + CUDA 는 되고 Vulkan 만 GPU 를 0개로 본다. 에러 없이 검은 화면만 나온다. + - NFS 가 잠깐 끊기면 데이터셋 경로가 '없는 것처럼' 보인다. + - gsplat 의 CUDA 확장이 빌드는 됐는데 아키텍처가 안 맞으면 첫 backward 에서야 터진다. +학습을 40분 돌린 뒤에 아는 것보다 여기서 아는 게 낫다. +""" + +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import sys + +_OK, _WARN, _FAIL = "✔", "!", "✘" +_C = {"✔": "\033[1;32m", "!": "\033[1;33m", "✘": "\033[1;31m"} +_RESET = "\033[0m" + +_failures = 0 +_warnings = 0 + + +def _line(status: str, label: str, detail: str = "") -> None: + global _failures, _warnings + if status == _FAIL: + _failures += 1 + elif status == _WARN: + _warnings += 1 + color = _C.get(status, "") + print(f" {color}{status}{_RESET} {label:<22} {detail}") + + +def _head(title: str) -> None: + print(f"\n\033[1;36m{title}\033[0m") + + +def _run(cmd: list[str], timeout: int = 20) -> tuple[int, str]: + try: + p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return p.returncode, (p.stdout or "") + (p.stderr or "") + except FileNotFoundError: + return 127, "실행파일 없음" + except subprocess.TimeoutExpired: + return 124, "시간 초과" + + +# --------------------------------------------------------------------------- 검사들 +def check_runtime() -> None: + _head("런타임") + _line(_OK, "python", f"{platform.python_version()} ({sys.executable})") + _line(_OK, "platform", f"{platform.system()} {platform.release()}") + in_docker = os.path.exists("/.dockerenv") + in_local = os.environ.get("CONDA_DEFAULT_ENV") == "vkgs" + if in_docker: + _line(_OK, "실행 환경", "Docker 컨테이너") + elif in_local: + _line(_OK, "실행 환경", f"로컬 micromamba 'vkgs' ({os.environ.get('CONDA_PREFIX', '')})") + else: + _line(_WARN, "실행 환경", "컨테이너도 vkgs 환경도 아님 — source scripts/local_env.sh 를 했는지 확인하세요") + + +def check_cuda() -> None: + _head("CUDA / PyTorch") + try: + import torch + except ImportError as e: + _line(_FAIL, "torch", f"임포트 실패: {e}") + return + _line(_OK, "torch", torch.__version__) + + if not torch.cuda.is_available(): + _line(_FAIL, "cuda", "torch.cuda.is_available() == False " + "→ --gpus all 없이 컨테이너를 띄웠거나 드라이버 불일치") + return + n = torch.cuda.device_count() + _line(_OK, "cuda", f"{torch.version.cuda}, 장치 {n}개") + for i in range(n): + p = torch.cuda.get_device_properties(i) + cap = f"sm_{p.major}{p.minor}" + vram = p.total_memory / 1024**3 + note = "" + if vram < 10: + note = " ← 8 GB 급: 3DGS 학습 시 다운스케일/가우시안 수 제한 필요" + _line(_OK, f"gpu[{i}]", f"{p.name} {vram:.1f} GB {cap}{note}") + + # 실제로 커널이 도는지. is_available() 만으로는 부족하다. + try: + a = torch.randn(256, 256, device="cuda") + torch.cuda.synchronize() + _line(_OK, "cuda 연산", f"검증 통과 ({float((a @ a.T).sum()):.3e})") + except Exception as e: + _line(_FAIL, "cuda 연산", f"{type(e).__name__}: {e}") + + +def check_gsplat() -> None: + _head("3DGS 학습 백엔드") + try: + import gsplat + except ImportError as e: + _line(_FAIL, "gsplat", f"임포트 실패: {e}") + return + _line(_OK, "gsplat", getattr(gsplat, "__version__", "?")) + try: + import torch + from gsplat import rasterization + if not torch.cuda.is_available(): + _line(_WARN, "gsplat 래스터화", "CUDA 가 없어 건너뜀") + return + # 가우시안 1개짜리 최소 래스터화. CUDA 확장이 이 GPU 아키텍처로 + # 실제 빌드됐는지 여기서 드러난다. + dev = "cuda" + means = torch.zeros(1, 3, device=dev); means[0, 2] = 5.0 + quats = torch.tensor([[1.0, 0, 0, 0]], device=dev) + scales = torch.full((1, 3), 0.3, device=dev) + opac = torch.ones(1, device=dev) + colors = torch.ones(1, 3, device=dev) + viewmat = torch.eye(4, device=dev)[None] + K = torch.tensor([[[100.0, 0, 32.0], [0, 100.0, 32.0], [0, 0, 1.0]]], device=dev) + img, _, _ = rasterization(means, quats, scales, opac, colors, viewmat, K, 64, 64) + torch.cuda.synchronize() + _line(_OK, "gsplat 래스터화", f"출력 {tuple(img.shape)}, 최대값 {float(img.max()):.3f}") + except Exception as e: + _line(_FAIL, "gsplat 래스터화", f"{type(e).__name__}: {e}") + + +def check_vulkan() -> None: + _head("Vulkan") + # 호스트는 /usr/share (드라이버 패키지), 컨테이너 이미지는 /etc 에 둔다 (docker/Dockerfile). VK_DRIVER_FILES 가 우선. + cands = [f for f in os.environ.get("VK_DRIVER_FILES", "").split(":") if f] + for icd_dir in ("/usr/share/vulkan/icd.d", "/etc/vulkan/icd.d"): + if os.path.isdir(icd_dir): + cands += [f"{icd_dir}/{f}" for f in sorted(os.listdir(icd_dir))] + nvidia_icd = [f for f in cands if "nvidia" in os.path.basename(f).lower() and os.path.isfile(f)] + if nvidia_icd: + _line(_OK, "NVIDIA ICD", nvidia_icd[0]) + else: + _line(_FAIL, "NVIDIA ICD", "nvidia ICD 가 없습니다 (/usr/share · /etc vulkan/icd.d) — " + "NVIDIA_DRIVER_CAPABILITIES 에 graphics 가 빠졌을 가능성이 큽니다") + + if not shutil.which("vulkaninfo"): + _line(_WARN, "vulkaninfo", "설치되지 않음 (vulkan-tools)") + return + rc, out = _run(["vulkaninfo", "--summary"]) + if rc != 0: + _line(_FAIL, "vulkaninfo", f"종료코드 {rc}: {out.strip().splitlines()[-1][:120] if out.strip() else ''}") + return + + gpus = [l.strip() for l in out.splitlines() if "deviceName" in l] + api = next((l.strip() for l in out.splitlines() if "apiVersion" in l), "") + if gpus: + _line(_OK, "Vulkan 장치", f"{len(gpus)}개") + for g in gpus: + _line(_OK, "", g) + if api: + _line(_OK, "apiVersion", api.split("=")[-1].strip()) + else: + _line(_FAIL, "Vulkan 장치", "0개 — ICD 는 있는데 물리장치가 안 잡힙니다") + + for tool, pkg in (("glslangValidator", "glslang-tools"), ("glslc", "glslc")): + if shutil.which(tool): + _line(_OK, f"셰이더 컴파일러", tool) + break + else: + _line(_FAIL, "셰이더 컴파일러", "glslangValidator / glslc 둘 다 없음") + + if os.environ.get("DISPLAY"): + _line(_OK, "DISPLAY", os.environ["DISPLAY"] + " (인터랙티브 뷰어 가능)") + else: + _line(_WARN, "DISPLAY", "없음 — 헤드리스. 오프라인 배치 렌더링은 정상 동작합니다") + + +def check_dataset() -> None: + _head("nuScenes 데이터셋") + try: + from nusim.paths import dataset + ds = dataset() + except Exception as e: + _line(_FAIL, "데이터셋 루트", str(e).splitlines()[0]) + return + _line(_OK, "루트", str(ds.root)) + for name, (exists, path) in ds.check().items(): + _line(_OK if exists else _FAIL, name, str(path) if not exists else "") + + # NFS 는 마운트돼 보여도 응답이 죽어 있을 수 있다. 실제로 한 파일을 읽어 본다. + try: + import time + t = time.time() + (ds.meta / "scene.json").read_bytes() + _line(_OK, "읽기 시험", f"scene.json {(time.time()-t)*1000:.0f} ms") + except Exception as e: + _line(_FAIL, "읽기 시험", f"{type(e).__name__}: {e}") + + +def check_index() -> None: + _head("씬 인덱스") + try: + from nusim.data.index import load_catalog + cat = load_catalog() + except FileNotFoundError: + _line(_WARN, "카탈로그", "아직 없음 — nusim index build 를 실행하세요") + return + except Exception as e: + _line(_FAIL, "카탈로그", f"{type(e).__name__}: {e}") + return + import collections + by = collections.Counter(s["location"] for s in cat["scenes"]) + total = sum(s["n_images"] for s in cat["scenes"]) + _line(_OK, "카탈로그", f"씬 {len(cat['scenes'])}개 / 이미지 {total:,}장 (생성 {cat['built_at']})") + for loc, n in sorted(by.items()): + _line(_OK, "", f"{loc:26s} {n:4d} 씬") + if cat.get("dataset_root"): + from nusim.paths import nuscenes_root + cur = str(nuscenes_root()) + if cat["dataset_root"] != cur: + _line(_WARN, "루트 불일치", + f"인덱스는 {cat['dataset_root']} 기준으로 만들어졌는데 현재는 {cur} 입니다. " + "파일 상대경로는 같으므로 보통 무해하지만, 이상하면 --force 로 재생성하세요.") + + +def check_workspace() -> None: + _head("작업공간") + from nusim.paths import cache_dir, output_dir, workspace_root + _line(_OK, "workspace", str(workspace_root())) + for label, d in (("cache", cache_dir()), ("outputs", output_dir())): + writable = os.access(d, os.W_OK) + _line(_OK if writable else _FAIL, label, + str(d) if writable else f"{d} ← 쓰기 불가. uid 불일치일 수 있습니다 (HOST_UID/.env 확인)") + + +def main() -> int: + print("\033[1mvkgs-sim 환경 진단\033[0m") + for fn in (check_runtime, check_cuda, check_gsplat, check_vulkan, + check_dataset, check_index, check_workspace): + try: + fn() + except Exception as e: # 한 항목이 터져도 나머지는 봐야 한다 + _line(_FAIL, fn.__name__, f"검사 자체가 실패: {type(e).__name__}: {e}") + + print() + if _failures: + print(f"\033[1;31m{_failures}개 실패\033[0m, {_warnings}개 경고") + return 1 + if _warnings: + print(f"\033[1;33m문제 없음 ({_warnings}개 경고)\033[0m") + return 0 + print("\033[1;32m모두 정상\033[0m") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/nusim/nusim/cli/main.py b/src/nusim/nusim/cli/main.py new file mode 100644 index 0000000000000000000000000000000000000000..fcfeb4d0b1ddf3f4957987811307d3a5e38a9ea9 --- /dev/null +++ b/src/nusim/nusim/cli/main.py @@ -0,0 +1,1487 @@ +"""nusim — 프로젝트 커맨드라인. + +typer/rich 가 아니라 argparse 로 쓴 이유: 이 CLI 는 컨테이너 밖 호스트에서도 +(의존성 설치 없이) 돌아야 진단에 쓸모가 있다. 표준 라이브러리만 쓴다. +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + + +LOG = logging.getLogger("nusim.cli") + + +def _cmd_index_build(a: argparse.Namespace) -> int: + from nusim import SINGAPORE_MAPS + from nusim.data.index import build_index + + locations = a.locations or list(SINGAPORE_MAPS) + build_index( + locations=locations, + include_sweeps=not a.no_sweeps, + force=a.force, + ) + return 0 + + +def _cmd_index_list(a: argparse.Namespace) -> int: + from nusim.data.index import load_catalog + + cat = load_catalog() + rows = cat["scenes"] + if a.location: + rows = [r for r in rows if r["location"] == a.location] + if a.grep: + g = a.grep.lower() + rows = [r for r in rows if g in r["description"].lower() or g in r["name"]] + if a.limit: + rows = rows[: a.limit] + + print(f"{'scene':<12} {'location':<26} {'imgs':>6} {'sec':>6} description") + print("-" * 100) + for r in rows: + print(f"{r['name']:<12} {r['location']:<26} {r['n_images']:>6} " + f"{r['duration_s']:>6.1f} {r['description'][:44]}") + print(f"\n{len(rows)}개 씬 / 이미지 {sum(r['n_images'] for r in rows):,}장") + return 0 + + +def _cmd_scene_show(a: argparse.Namespace) -> int: + from nusim.data.index import load_scene + from nusim.paths import dataset + + si = load_scene(a.name) + ds = dataset() + print(f"\n\033[1m{si.name}\033[0m {si.location}") + print(f" {si.description}") + print(f" 차량 {si.vehicle} / 촬영일 {si.date_captured} / {si.duration_s():.1f}초") + print(f" 키프레임 {si.nbr_samples}개 / 전체 이미지 {si.n_images:,}장\n") + print(f" {'camera':<17} {'frames':>7} {'keyfr':>6} {'Hz':>6} {'fx':>8} {'HFOV':>6}") + for cam, frames in si.frames.items(): + if not frames: + continue + span = (frames[-1].timestamp - frames[0].timestamp) / 1e6 + hz = (len(frames) - 1) / span if span > 0 else 0.0 + c = si.calib[cam] + import math + fx = c.intrinsic[0][0] + hfov = math.degrees(2 * math.atan(c.width / (2 * fx))) + print(f" {cam:<17} {len(frames):>7} {len(si.keyframes(cam)):>6} {hz:>6.1f} {fx:>8.1f} {hfov:>5.1f}°") + + f0 = si.frames["CAM_FRONT"][0] + print(f"\n 첫 프레임: {f0.filename}") + print(f" 존재 여부: {f0.path(ds).exists()}") + return 0 + + +def _cmd_recon_init(a: argparse.Namespace) -> int: + from nusim.data.index import load_scene + from nusim.io.ply import write_ply + from nusim.paths import dataset, output_dir + from nusim.recon.init import InitConfig, init_gaussians + + import json + + from nusim.recon.init import init_meta + + cfg = InitConfig(voxel=a.voxel, max_points=a.max_points) + print(f"\033[1m{a.name}\033[0m 초기화") + g = init_gaussians(load_scene(a.name), dataset(), cfg) + path = write_ply(output_dir() / "gs" / a.name / "init.ply", g) + path.with_suffix(".json").write_text(json.dumps(init_meta(cfg), indent=2)) + print(f" → {path}") + return 0 + + +def _train_config(a: argparse.Namespace, **extra): + """recon train 옵션 → TrainConfig (단일 씬·타일 공용).""" + from nusim.recon.train import TrainConfig + + mcmc = a.strategy == "mcmc" + return TrainConfig(iters=a.iters, max_gaussians=a.max_gaussians, depth_lambda=a.depth_lambda, + depth_scale=a.depth_scale, strategy=a.strategy, sky=a.sky, objects=a.objects, + downscale=a.downscale, frame_stride=a.stride, keyframes_only=a.keyframes_only, + aniso_reg=a.aniso_reg, aniso_max=a.aniso_max, + ground_lambda=getattr(a, "ground_lambda", 0.0), + opacity_reg=a.opacity_reg if a.opacity_reg is not None else (0.01 if mcmc else 0.0), + scale_reg=a.scale_reg if a.scale_reg is not None else (0.01 if mcmc else 0.0), + # MCMC 는 cap 안에서 재배치만 하므로 오래 켜 둔다 (gsplat 기본 25k/30k ≈ 0.8) + refine_stop=a.refine_stop or (int(0.8 * a.iters) if mcmc else min(15_000, a.iters // 2)), + eval_every=a.eval_every, absgrad=a.absgrad, + grow_grad2d=a.grow_grad2d or (0.0008 if a.absgrad else 0.0002), + lr_camera=0.0 if a.no_camera_adjust else TrainConfig.lr_camera, + time_window=a.time_window, **extra, image_root=str(Path(a.image_root).resolve()) if a.image_root else None) + + +def _train_tile(a: argparse.Namespace, out: Path) -> int: + """여러 주행을 한 모델로 (recon.tile). a.name 은 무시하고 타일의 anchor 를 쓴다.""" + import json + + from nusim.io.ply import read_ply, write_ply + from nusim.log import run_log + from nusim.paths import dataset + from nusim.recon.init import InitConfig, init_meta + from nusim.recon.tile import ALIGN_VERSION, Tile, tile_init, tile_lidar, tile_scenes, tile_views + from nusim.recon.train import train + + if a.objects == "vehicles": + raise SystemExit("타일 학습은 아직 --objects vehicles 를 지원하지 않는다 (주행마다 차량이 다르다) — agents 로") + tile, ds = Tile.load(a.tile), dataset() + if a.name != tile.anchor: + print(f" 타일 {tile.name} 의 기준 씬은 {tile.anchor} — {a.name} 대신 그걸 쓴다") + scenes = tile_scenes(tile) + icfg = InitConfig(sky_points=0 if a.sky else 30_000, objects=a.objects) + init_ply = out / "init.ply" + want = {**init_meta(icfg), "tile": json.loads(json.dumps( + {"train": tile.train, "center": tile.center.tolist(), "half_m": tile.half_m, + "align": ALIGN_VERSION if tile.align else 0}))} + meta_path = init_ply.with_suffix(".json") + out.mkdir(parents=True, exist_ok=True) + if not init_ply.exists() or not meta_path.exists() or json.loads(meta_path.read_text()) != want: + write_ply(init_ply, tile_init(tile, scenes, ds, icfg, max_total=icfg.max_points)) + meta_path.write_text(json.dumps(want, indent=2)) + init = read_ply(init_ply) + views = tile_views(scenes, ds, init.origin, objects=a.objects, downscale=a.downscale, + frame_stride=a.stride, keyframes_only=a.keyframes_only) + pts = None + if a.depth_lambda > 0: + from nusim.recon.depth import lidar_depth_maps + + pts = tile_lidar(scenes, ds, InitConfig(objects=a.objects), init.origin) + lidar_depth_maps(pts, views.train, scale=a.depth_scale) + if a.sky: + # 하늘 = 큐브맵 + 하늘 픽셀에서 스플랫 α→0. 날마다 하늘 밝기가 달라, 하늘을 따로 두지 않으면 가우시안이 부풀어 + # 하늘을 덮다 카메라까지 감쌌다 (6주행 15k: 10 m 넘는 가우시안 685개, 자기 주행 평가 12.7 dB) + from nusim.recon.sky import sky_masks + + for k, s in enumerate(scenes): + sky_masks([v for v in views.train if v.cam_id // 6 == k], ds, sky_mask_cache(s.name, ds)) + cfg = _train_config(a, tile=tile.name, reg_visible=True) + with run_log(out / "run.log"): + LOG.info("타일 학습 %s (%d주행: %s, 뺀 주행 %s) → %s", tile.name, len(scenes), ", ".join(tile.train), + ", ".join(tile.heldout) or "없음", out) + train(views, init, out, cfg, depth_points=pts) + return 0 + + +def _cmd_recon_tile_eval(a: argparse.Namespace) -> int: + import json + + from nusim.log import run_log + from nusim.paths import dataset, output_dir + from nusim.recon.tile import Tile, tile_eval + + tile = Tile.load(a.tile) + out = Path(a.out) if a.out else output_dir() / "tiles" / tile.name + out.mkdir(parents=True, exist_ok=True) + with run_log(out / "eval.log"): + res = tile_eval(tile, [Path(d) for d in a.dir], dataset(), stride=a.stride, save_dir=out / "renders") + (out / "eval.json").write_text(json.dumps(res, indent=2, ensure_ascii=False)) + print(f"\n 뺀 주행 {', '.join(tile.heldout)} 의 실제 사진 (색 아핀 맞춤 후, 사람·차량 가림)") + print(f" 차선 변경 시점 = {tile.anchor} 의 같은 방향 카메라가 옆 6 m 안에 있는 뷰") + for d, r in res["models"].items(): + dist = " ".join(f"{k} {v['psnr']:.2f}/{v['ssim']:.3f}({v['n']})" for k, v in r["by_dist"].items() if v["n"]) + print(f" {Path(d).parent.name + '/' + Path(d).name:<36} PSNR {r['psnr']:.2f} SSIM {r['ssim']:.4f} " + f"| 옆 거리별 PSNR/SSIM: {dist} | 전체 {r['all']['psnr']:.2f}") + print(f" → {out / 'eval.json'}") + return 0 + + +def _cmd_recon_train(a: argparse.Namespace) -> int: + from nusim.data.index import load_scene + from nusim.io.ply import read_ply, write_ply + from nusim.paths import dataset, output_dir + from nusim.recon.dataset import build_views + from nusim.recon.init import init_gaussians + from nusim.recon.train import TrainConfig, train + + out = Path(a.out) if a.out else output_dir() / "gs" / a.name + if a.tile: + return _train_tile(a, out) + scene, ds = load_scene(a.name), dataset() + if a.image_root: # 카메라 이미지만 다른 곳에서 (밤·비 → 낮 변환 등, 같은 상대 경로) + from dataclasses import replace as _replace + + ds = _replace(ds, image_root=Path(a.image_root)) + print(f" 카메라 이미지 루트 {ds.images}") + if a.time_window: + t0, t1 = (float(x) for x in a.time_window.split(":")) + scene = scene.window(t0, t1) + print(f" 시간 구간 {t0:g}~{t1:g} 초 — 카메라 {scene.n_images}장, 라이다 {len(scene.lidar)}장") + # 초기화는 결과 디렉터리마다 따로 둔다 — 설정(sweep 여부 등)이 다른 실험이 서로의 init 을 쓰지 않게 + init_ply = out / "init.ply" + print(f"\033[1m{a.name}\033[0m {scene.description}") + import json + + from nusim.recon.init import InitConfig, init_meta + + # 하늘을 큐브맵으로 분리하면 먼 구면 가우시안(하늘 구)은 필요 없다 — 오히려 floater 원인 + # vehicles: 배경 초기화·깊이 지도는 차량까지 뺀 라이다로 (차량은 객체 가우시안이 따로 맡는다 — recon.objects) + bg_objects = "agents" if a.objects == "vehicles" else a.objects + icfg = InitConfig(sky_points=0 if a.sky else 30_000, objects=bg_objects) + meta_path = init_ply.with_suffix(".json") + old_meta = json.loads(meta_path.read_text()) if meta_path.exists() else None + want_meta = {**init_meta(icfg), **({"time_window": a.time_window} if a.time_window else {}), + **({"image_root": str(ds.images)} if a.image_root else {})} # 색은 이 이미지에서 + if not init_ply.exists() or old_meta != want_meta: + if init_ply.exists(): + print(" init.ply 를 다시 만듭니다 (설정 또는 초기화 알고리즘 판이 다름)") + write_ply(init_ply, init_gaussians(scene, ds, icfg)) + meta_path.write_text(json.dumps(want_meta, indent=2)) + init = read_ply(init_ply) + views = build_views(scene, ds, init.origin, downscale=a.downscale, + frame_stride=a.stride, keyframes_only=a.keyframes_only, objects=a.objects) + if a.depth_lambda > 0: + from nusim.recon.depth import lidar_depth_maps + from nusim.recon.init import InitConfig, accumulate_lidar + + pts = accumulate_lidar(scene, ds, InitConfig(objects=bg_objects)) - init.origin # 가린 객체의 점은 뺀다 + lidar_depth_maps(pts, views.train, scale=a.depth_scale) + else: + pts = None + if a.sky: + from nusim.paths import cache_dir + from nusim.recon.sky import sky_masks + + sky_masks(views.train, ds, sky_mask_cache(a.name, ds)) + cfg = _train_config(a) + from nusim.log import run_log + + with run_log(out / "run.log"): + LOG.info("학습 시작 %s → %s (strategy=%s, sky=%s, depth_lambda=%s, objects=%s)", a.name, out, a.strategy, + a.sky, a.depth_lambda, a.objects) + objs = obj_init = None + if a.objects == "vehicles": + from nusim.recon.objects import ObjectSet, init_objects + + objs = ObjectSet.from_scene(scene, init.origin) + obj_init = init_objects(objs, scene, ds, icfg, log=print) + gmask = None + if cfg.ground_lambda > 0: + from nusim.recon.ground import ground_mask + + gmask = ground_mask(init.means, scene, init.origin, ds) + train(views, init, out, cfg, depth_points=pts, objects=objs, obj_init=obj_init, ground=gmask) + return 0 + + +def _model_views(d: Path) -> dict: + """모델을 학습한 뷰 집합 설정 (report.json 의 config). 옛 모델은 기본값.""" + import json + + out = {"downscale": 2, "frame_stride": 1, "keyframes_only": False} + try: + cfg = json.loads((d / "report.json").read_text())["config"] + out.update({k: cfg[k] for k in out if k in cfg}) + except (OSError, KeyError, ValueError): + pass + return out + + +def _model_data(name: str, d: Path): + """(씬, 데이터 경로) — 모델을 학습한 데이터 범위 그대로 (report.json config 의 time_window·image_root). + 평가·증류·정리·하늘 표시·사이클이 모두 이걸로 — 구간 모델에 씬 전체 뷰를 주면 색 보정 크기가 안 맞고, + 변환 영상으로 학습한 모델을 원본(밤) 사진으로 평가하게 된다 (2026-10-05 검토).""" + import json + from dataclasses import replace + + from nusim.data.index import load_scene + from nusim.paths import dataset + + try: + cfg = json.loads((d / "report.json").read_text())["config"] + except (OSError, KeyError, ValueError): + cfg = {} + scene, ds = load_scene(name), dataset() + if cfg.get("time_window"): + t0, t1 = (float(x) for x in cfg["time_window"].split(":")) + scene = scene.window(t0, t1) + if cfg.get("image_root"): + ds = replace(ds, image_root=Path(cfg["image_root"])) + return scene, ds + + +def sky_mask_cache(name: str, ds) -> Path: + """하늘 마스크 캐시 — 이미지 루트마다 따로 (원본 밤 사진과 변환한 낮 영상의 마스크를 섞지 않게).""" + from nusim.paths import cache_dir + + tag = name if ds.image_root is None else f"{name}@{Path(ds.image_root).name}" + return cache_dir() / "sky_masks" / tag + + +def _model_objects(d: Path) -> str: + """모델이 학습 때 가린 객체 종류 (report.json 의 config.objects). 옛 모델은 dynamic.""" + import json + + try: + return json.loads((d / "report.json").read_text())["config"].get("objects", "dynamic") + except (OSError, KeyError, ValueError): + return "dynamic" + + +def _cmd_recon_eval(a: argparse.Namespace) -> int: + import json + + import torch + + from nusim.data.index import load_scene + from nusim.io.ply import read_ply + from nusim.paths import dataset, output_dir + from nusim.recon.dataset import build_views + from nusim.recon.train import CameraAdjust, ColorCorrection, _to_params, evaluate + + d = Path(a.dir) if a.dir else output_dir() / "gs" / a.name + g = read_ply(d / "point_cloud.ply") + objects = _model_objects(d) + vk = _model_views(d) + scene, ds = _model_data(a.name, d) + views = build_views(scene, ds, g.origin, downscale=a.downscale or vk["downscale"], + frame_stride=vk["frame_stride"], keyframes_only=vk["keyframes_only"], objects=objects) + color = ColorCorrection.load(d / "color_correction.pt", 6, len(views.train) + len(views.test), "cuda") + adj = None + if (d / "camera_adjust.pt").exists(): + adj = CameraAdjust(6).cuda() + adj.load_state_dict(torch.load(d / "camera_adjust.pt", weights_only=True)) + sky = None + if (d / "sky.pt").exists(): + from nusim.recon.train import SkyCube + + try: + sky = SkyCube.from_state(torch.load(d / "sky.pt", weights_only=True)) + except ValueError as e: + LOG.warning("하늘 큐브맵을 못 읽음: %s", e) + params = _to_params(g, "cuda") + objposes = None + if objects == "vehicles" and (d / "objects" / "objects.json").exists(): + # 차량 동시 학습 모델: 배경 ply 에 차량 에셋을 다시 붙여 학습 때처럼 그린다 + from nusim.recon.objects import load_objects + + arrays, ids, objs = load_objects(d, scene, g.origin) + if len(ids): + for key, val in arrays.items(): + params[key] = torch.nn.Parameter(torch.cat([params[key].data, val], 0)) + params["obj"] = torch.nn.Parameter(torch.cat([torch.full((len(g),), -1.0, device="cuda"), + torch.as_tensor(ids, device="cuda")])) + cache: dict[int, tuple] = {} + + def objposes(v): + t = int(v.pose.timestamp) + if t not in cache: + cache[t] = tuple(torch.as_tensor(x, device="cuda", dtype=torch.float32) for x in objs.poses_at(t)) + return cache[t] + print(f" 차량 에셋 {len(set(ids.tolist()))}대, 가우시안 {len(ids):,}개를 붙여 평가") + ev = evaluate(params, color, views, g.sh_degree, cam_adjust=adj, sky=sky, objposes=objposes, with_lpips=a.lpips) + if a.geom: + from nusim.paths import cache_dir + from nusim.recon.depth import lidar_depth_maps + from nusim.recon.init import InitConfig, accumulate_lidar + from nusim.recon.metrics import geometry_metrics + from nusim.recon.sky import sky_masks + + pts = accumulate_lidar(scene, ds, InitConfig(objects=objects)) - g.origin + if adj is not None: # 렌더와 같은 (보정된) 카메라로 투영한다 + from nusim.recon.train import _refresh_depth + + _refresh_depth(views.test, adj, pts, 4) + else: + lidar_depth_maps(pts, views.test, scale=4) + sky_masks(views.test, ds, sky_mask_cache(a.name, ds)) + ev.update(geometry_metrics(params, views.test, g.sh_degree, cam_adjust=adj)) + prev = json.loads((d / "eval.json").read_text()) if (d / "eval.json").exists() else {} + (d / "eval.json").write_text(json.dumps({**prev, **ev}, indent=2)) # 이전 --geom 등 다른 키는 남긴다 + print(json.dumps(ev, indent=2)) + return 0 + + +def _cmd_run(a: argparse.Namespace) -> int: + from nusim.config import resolve + from nusim.jobs import run_job + from nusim.log import run_log + from nusim.paths import output_dir + + path = resolve("jobs", a.job) + with run_log(output_dir() / "jobs" / f"{path.stem}.log"): + return run_job(path, dry=a.dry_run) + + +def _cmd_recon_batch(a: argparse.Namespace) -> int: + from nusim.paths import output_dir + from nusim.recon.batch import day_scenes, run_batch + + if a.scenes: + scenes = [s.strip() for s in a.scenes.split(",") if s.strip()] + else: + scenes = day_scenes(a.location) + if a.order == "coverage": + from nusim.recon.batch import coverage_order + + scenes = coverage_order(scenes) + if a.limit: + scenes = scenes[: a.limit] + root = Path(a.root) if a.root else output_dir() / "gs_day" + print(f"일괄 재구성 {len(scenes)}씬 (프리셋 {a.preset}) → {root}") + from nusim.log import run_log + + with run_log(root / "batch.log"): + status = run_batch(scenes, root, a.preset, iters=a.iters, sky=False if a.no_sky else None, + segment_m=a.segment_m, overlap_m=a.overlap_m, after=a.after, keep_init=a.keep_init) + units = [v for k, v in status.items() if not k.startswith("_")] + return 0 if all(v.get("state") == "done" for v in units) else 1 + + +def _cmd_recon_cycle(a: argparse.Namespace) -> int: + import json + + from nusim.log import run_log + from nusim.paths import output_dir + from nusim.recon.cycle import bench_metrics, build_cycle + + base = Path(a.base) if a.base else output_dir() / "gs_day" / a.name + with run_log(base / "cycle" / "run.log"): + cdir = build_cycle(a.name, base, a.iters, a.shift) + res = {k: bench_metrics(cdir / "bench", k) for k in ("render", "base")} + (cdir / "bench_metrics.json").write_text(json.dumps(res, indent=2)) + print(json.dumps(res, indent=2)) + return 0 + + +def _cmd_recon_prune(a: argparse.Namespace) -> int: + from nusim.log import run_log + from nusim.recon.prune import prune_model + + src = Path(a.dir) if a.dir else _render_paths(a.name) + out = Path(a.out) if a.out else src.parent / f"{src.name}_pruned" + with run_log(out / "prune.log"): + prune_model(a.name, src, out, margin=a.margin, ratio=a.ratio, min_obs=a.min_obs) + return 0 + + +def _cmd_recon_bench(a: argparse.Namespace) -> int: + import json + + from nusim.recon.cycle import bench_metrics, run_fixer_bench + + bench = Path(a.bench) + if a.run_difix: + run_fixer_bench(bench) + res = {k: bench_metrics(bench, k) for k in a.pred.split(",")} + print(json.dumps(res, indent=2)) + return 0 + + +def _cmd_recon_skylabel(a: argparse.Namespace) -> int: + from nusim.log import run_log + from nusim.recon.sky_label import label_sky + + d = Path(a.dir) if a.dir else _render_paths(a.name) + with run_log(d / "sky_label.log"): + label_sky(a.name, d, ratio=a.ratio, min_obs=a.min_obs) + return 0 + + +def _cmd_recon_semantic(a: argparse.Namespace) -> int: + from nusim.data.index import load_scene + from nusim.paths import dataset, output_dir + from nusim.recon.batch import segment_dirs + from nusim.recon.semantic import is_current, semantic_config, write_semantic + + root = Path(a.root) if a.root else output_dir() / "gs_day" + names = list(a.names) + if a.location: + from nusim.render.regionmap import trained_scenes + + names += [s for s, _ in trained_scenes(root, a.location)] + if not names: + raise SystemExit("씬 이름이나 --location 을 준다") + cfg, paths, n = semantic_config(), dataset(), 0 + for i, name in enumerate(names): + dirs = [m for _, m in segment_dirs(root, name)] or ([Path(a.dir)] if a.dir else [_render_paths(name)]) + todo = [m for m in dirs if a.force or not is_current(m)] + if not todo: + continue + print(f"[{i + 1}/{len(names)}] {name}: 모델 {len(todo)}개") + sc, cache = load_scene(name), {} + for m in todo: + write_semantic(m, sc, paths, cfg, log=print, cache=cache) + n += 1 + print(f"시맨틱 라벨 {n}개 모델 새로 만듦") + return 0 + + +def _ensure_sky_label(name: str, d: Path) -> None: + """하늘을 분리하지 않고 학습한 모델이면 하늘 가우시안을 표시해 둔다 (뷰어가 시뮬레이션 하늘 모드에서 뺀다). + 하늘 큐브맵(sky.ktx2)이 있는 모델은 하늘이 이미 따로라 필요 없다.""" + from nusim.recon.sky_label import is_fresh, label_sky + + if (d / "sky.ktx2").exists() or is_fresh(d): + return + print(" 하늘 가우시안 표시 (한 번만, 하늘 마스크 캐시 사용)…") + label_sky(name, d, log=print) + + +def _cmd_aug_rig(a: argparse.Namespace) -> int: + from nusim.aug.rig_aug import load_variants, render_variant + from nusim.config import configs_dir + from nusim.log import run_log + from nusim.paths import output_dir + from nusim.render.compare import viewer_binary + + d = _render_paths(a.name, a.model) + _ensure_export(a.name, d, 1) + variants = load_variants(Path(a.variants) if a.variants else configs_dir() / "aug" / "rig_variants.json", + a.only.split(",") if a.only else None) + out_root = Path(a.out) if a.out else output_dir() / "aug" / "rig" + extra = _ibl_gltf_args(a.name, agents=True) if a.agents else [] + with run_log(out_root / a.name / "aug.log"): + print(f"\033[1m{a.name}\033[0m 리그 변형 {len(variants)}개 (모델 {d})") + for v in variants: + render_variant(a.name, d, v, out_root, viewer_binary(), extra, log=print) + return 0 + + +def _cmd_aug_map(a: argparse.Namespace) -> int: + from nusim.aug.map_aug import aug_scene + from nusim.aug.rig_aug import load_variants + from nusim.config import configs_dir + from nusim.log import run_log + from nusim.paths import output_dir + from nusim.render.compare import viewer_binary + + variants = load_variants(Path(a.variants) if a.variants else configs_dir() / "aug" / "rig_variants.json", + a.only.split(",") if a.only else None) + if not a.only: # 기본은 날씨 없는 변형만 — 날씨 합성은 논문 범위 밖 (2026-10-06) + variants = [v for v in variants if not v.get("weather")] + root = Path(a.root) if a.root else output_dir() / "gs_day" + out_root = Path(a.out) if a.out else output_dir() / "aug" / "map" + with run_log(out_root / a.name / "aug.log"): + print(f"\033[1m{a.name}\033[0m 리그 변형 {len(variants)}개 (모델 {root / a.name}, " + f"{'키프레임' if a.keyframes_only else '전체 프레임'}, 차량 {'glTF' if a.agents else '없음'})") + aug_scene(a.name, root, variants, out_root, viewer_binary(), keyframes=a.keyframes_only, agents=a.agents, + log=print) + return 0 + + +def _cmd_aug_artifixer(a: argparse.Namespace) -> int: + from nusim.aug.artifixer_run import aug_scene, artifixer_config, done_cams, finished_scenes + from nusim.log import run_log + from nusim.paths import output_dir + + cfg = artifixer_config() + cams = a.cams.split(",") if a.cams else cfg["cams"] + names = list(a.names) or finished_scenes() + if a.limit: + names = [n for n in names if a.force or not set(cams) <= done_cams(n)][:a.limit] + log_dir = output_dir() / "aug" / "aug_artfixer" + log_dir.mkdir(parents=True, exist_ok=True) + failed = [] + with run_log(log_dir / "artifixer.log"): + for i, name in enumerate(names): + if not a.force and set(cams) <= done_cams(name): + continue + print(f"\033[1m[{i + 1}/{len(names)}] {name}\033[0m ArtiFixer + 차선 후처리 ({','.join(cams)})", flush=True) + try: + aug_scene(name, cfg, Path(a.root) if a.root else None, cams, force=a.force) + except Exception as e: # 씬 하나가 깨져도 (PNG 손상 · NFS 끊김) 나머지는 계속 + print(f" 실패: {e}") + failed.append(name) + if failed: + print(f"실패 {len(failed)}씬: {' '.join(failed)}") + return 1 if failed else 0 + + +def _cmd_aug_translate(a: argparse.Namespace) -> int: + from nusim.aug.translate import translate_scene + from nusim.log import run_log + from nusim.paths import output_dir + + out_root = Path(a.out) if a.out else output_dir() / "aug" / "translate" + w, h = (int(x) for x in a.size.split("x")) + with run_log(out_root / "translate.log"): + for name in a.names: + translate_scene(name, a.models.split(","), out_root, size=(w, h), stride=a.stride, + keyframes_only=a.keyframes_only, log=print) + return 0 + + +def _is_seg(d: Path) -> bool: + from nusim.render.segmap import is_segmented + + return is_segmented(d) + + +def _render_paths(name: str, model: str | None = None, segmented: bool = False): + """렌더에 쓸 모델 디렉터리. 기본은 outputs/gs/_env (환경 전용), 다음 outputs/gs/_depth (라이다 깊이 감독), + 없으면 outputs/gs/. 차선 변경 같은 새 시점에서 깊이 감독 모델이 확연히 낫다 + (docs/roadmap.md 1단계 모델 비교). segmented: 구간으로 나눈 일괄 재구성 씬 디렉터리(seg/)도 받는다 (render view).""" + from nusim.paths import output_dir + + if model: + d = Path(model) + else: + # 환경 전용(_env: 차량·사람 전부 지움) > 일괄 재구성(gs_day, 환경 전용) > 깊이 감독(_depth) > 기본 순 + cands = [output_dir() / "gs" / f"{name}_env", output_dir() / "gs_day" / name, + output_dir() / "gs" / f"{name}_depth", output_dir() / "gs" / name] + for d in cands: + if (d / "point_cloud.ply").exists() or (segmented and _is_seg(d)): + break + if not (d / "point_cloud.ply").exists() and not (segmented and _is_seg(d)): + raise FileNotFoundError(f"{d / 'point_cloud.ply'} 가 없습니다. 먼저: nusim recon train {name}") + return d + + +def _ibl_gltf_args(name: str, agents: bool = False) -> list[str]: + """IBL 이 구워져 있으면 환경 조명을 켠다. + 다른 차량(glTF)은 기본으로 넣지 않는다 — 렌더링은 주행 환경(맵)만 구성하고, 차량·사람 같은 객체는 + 이후 시뮬레이터가 따로 넣는 방향이다. agents=True 면 nuScenes 로그의 움직이는 차 궤적에 glTF 차량을 놓는다.""" + from nusim.paths import output_dir, workspace_root + + args = [] + ibl = output_dir() / "ibl" / name + if (ibl / "env_specular.ktx2").exists(): + args += ["--ibl", str(ibl)] + car = workspace_root() / "assets" / "gltf" / "CarConcept.glb" + if agents and car.exists(): + args += ["--gltf", str(car)] + return args + + +def _write_obstacles(name: str, d: Path, origin) -> None: + """충돌용 장애물 지도 (정적 라이다, 모델과 같은 objects 설정으로 차량·사람 제거) → d/obstacles.pgm""" + from nusim.data.index import load_scene + from nusim.paths import dataset + from nusim.sim.obstacles import write_obstacles + + info = write_obstacles(d / "obstacles.pgm", load_scene(name), dataset(), origin, objects=_model_objects(d)) + print(f" 장애물 지도 {info['cells']:,}칸 ({info['res']} m; 노면 안쪽에서 비운 칸 {info['cleared_on_road']}, " + f"원래 경로에서 더 비운 칸 {info['carved_by_log_path']}) → {d / 'obstacles.pgm'}") + + +EXPORT_VERSION = 2 # 내보내기 판 (바뀌면 다시 만든다). 2: 환경 전용 모델의 A 줄에 주차 차량 + + +def _model_stamp(d: Path) -> str: + """모델이 바뀌었는지 — ply 와 외부 파라미터 보정의 수정 시각.""" + parts = [str(int((d / "point_cloud.ply").stat().st_mtime))] + if (d / "camera_adjust.json").exists(): + parts.append(str(int((d / "camera_adjust.json").stat().st_mtime))) + return ":".join(parts) + + +def _ensure_export(name: str, d: Path, downscale: int, margin: float = 60.0, force: bool = False) -> None: + """뷰어가 읽는 파일(rig.txt, map.txt, drivable.txt, obstacles.pgm)을 필요한 것만 다시 만든다. + 예전에는 파일이 있기만 하면 그대로 써서, 모델을 다시 학습하거나 --downscale·objects·알고리즘을 바꿔도 + 낡은 리그·지도로 그렸다 (2026-10-02 리뷰). 무엇으로 만들었는지 export.json 에 남기고 비교한다.""" + import json + + from nusim.data.index import load_scene + from nusim.io.ply import read_origin + from nusim.render.export import load_camera_adjust, write_drivable, write_map_lines, write_rig + from nusim.sim.obstacles import OBSTACLES_VERSION + from nusim.sim.voxels import VOXELS_VERSION + + state_path = d / "export.json" + state = json.loads(state_path.read_text()) if state_path.exists() and not force else {} + stamp = _model_stamp(d) + want = { + "rig": {"model": stamp, "downscale": downscale, "version": EXPORT_VERSION}, + "map": {"model": stamp, "version": EXPORT_VERSION, "margin": margin}, + "obstacles": {"model": stamp, "version": OBSTACLES_VERSION, "objects": _model_objects(d)}, + "voxels": {"model": stamp, "version": VOXELS_VERSION, "objects": _model_objects(d)}, + } + files = {"rig": ["rig.txt"], "map": ["map.txt", "drivable.txt"], "obstacles": ["obstacles.pgm"], + "voxels": ["world.vox"]} + todo = [k for k in want if state.get(k) != want[k] or not all((d / f).exists() for f in files[k])] + if not todo: + return + scene, _ = _model_data(name, d) # 구간 모델(--time-window)이면 그 학습 구간만 재생·표시 + origin = read_origin(d / "point_cloud.ply") + if "rig" in todo: + adjust = load_camera_adjust(d) + n = write_rig(d / "rig.txt", scene, origin, adjust, downscale=downscale, objects=_model_objects(d)) + print(f" 카메라 리그 {n}프레임 × 6캠 → {d / 'rig.txt'}" + (" (외부 파라미터 보정 반영)" if adjust else "")) + if "map" in todo: + counts = write_map_lines(d / "map.txt", scene, origin, margin=margin) + n_poly = write_drivable(d / "drivable.txt", scene, origin) + print(f" 주행가능영역 {n_poly}개 폴리곤 → {d / 'drivable.txt'}") + print(f" 맵 선 → {d / 'map.txt'}") + for k, v in counts.items(): + print(f" {k:<16} {v:>5}") + if "obstacles" in todo: + _write_obstacles(name, d, origin) + if "voxels" in todo: # 라이다·레이더 레이캐스트용 (nusim/sim/voxels.py) + from nusim.paths import dataset + from nusim.sim.voxels import write_voxels + + info = write_voxels(d / "world.vox", scene, dataset(), origin, objects=_model_objects(d)) + print(f" 복셀 월드 {info['occupied']:,}칸 ({info['res']} m, {info['mb']} MB) → {d / 'world.vox'}") + state.update({k: want[k] for k in todo}) + state_path.write_text(json.dumps(state, indent=2)) + + +def _cmd_render_export(a: argparse.Namespace) -> int: + d = _render_paths(a.name, getattr(a, "model", None)) + _ensure_export(a.name, d, a.downscale, margin=a.margin, force=True) + return 0 + + +def _cmd_render_compare(a: argparse.Namespace) -> int: + import json + + from nusim.data.index import load_scene + from nusim.io.ply import read_origin + from nusim.paths import dataset + from nusim.recon.dataset import build_views + from nusim.render.compare import compare, run_offscreen + from nusim.render.export import load_camera_adjust, write_views + + d = _render_paths(a.name, getattr(a, "model", None)) + origin = read_origin(d / "point_cloud.ply") + vk = _model_views(d) + scene, ds = _model_data(a.name, d) + views = build_views(scene, ds, origin, load_images=False, mask_dynamic=False, + frame_stride=vk["frame_stride"], keyframes_only=vk["keyframes_only"]).test + views = views[:: max(1, len(views) // a.n)][: a.n] + adjust = load_camera_adjust(d) + write_views(d / "test_views.txt", views, adjust) + vk_dir = d / "vk_test" + for old in vk_dir.glob("*.png"): + old.unlink() + print(run_offscreen(d / "point_cloud.ply", d / "test_views.txt", vk_dir).rstrip()) + res = compare(d, views, vk_dir, adjust) + (d / "vk_compare.json").write_text(json.dumps(res, indent=2)) + ok = res["mean_abs_diff_pct"] < 2.0 + print(f"\n Vulkan vs gsplat ({res['n_views']}뷰): PSNR {res['psnr_vk_vs_gsplat']:.2f} dB " + f"평균 차이 {res['mean_abs_diff_pct']:.3f}% 2% 넘는 픽셀 {res['pixels_over_2pct']:.2f}% " + f"최악 뷰 {res['worst_view_psnr']:.2f} dB → {'합격' if ok else '불합격'} (기준: 평균 차이 < 2%)") + return 0 if ok else 1 + + +def _cmd_render_view(a: argparse.Namespace) -> int: + import os + + from nusim.render.compare import viewer_binary + + d = _render_paths(a.name, getattr(a, "model", None), segmented=True) + sky_dir = d + if _is_seg(d): # 일괄 재구성이 구간으로 나눈 씬 — 구간을 이어서 (자차 위치의 구간을 그린다) + from nusim.render.segmap import write_segment_map + + segs = write_segment_map(a.name, d, log=print) + ply, sky_dir = segs[0][1] / "point_cloud.ply", segs[0][1] + from nusim.aug.map_aug import ensure_ibl + + ensure_ibl(a.name, segs, log=print) # glTF 자차의 조명 — 없으면 3DGS 자차 에셋으로 대신해 검은 덩어리가 나왔다 + seg_args = ["--segments", str(d / "segments.txt")] + model_dirs = [m for _, m in segs] + else: + _ensure_export(a.name, d, a.downscale) + _ensure_sky_label(a.name, d) + ply, seg_args, model_dirs = d / "point_cloud.ply", [], [d] + from nusim.recon.semantic import ensure_semantic + + ensure_semantic(a.name, model_dirs, log=print) # 없으면 시맨틱 카메라(F3)가 하늘만 파랗고 나머지는 검다 + cmd = [str(viewer_binary()), str(ply.resolve()), "--cams", str(d / "rig.txt"), + "--lines", str(d / "map.txt"), "--shot-dir", str(d / "shots"), *seg_args] + if (d / "drivable.txt").exists(): + cmd += ["--drivable", str(d / "drivable.txt")] # 직접 운전(G) 때 도로 이탈 판정 + 연석 충돌 + cmd += ["--obstacles", str(d / "obstacles.pgm")] # 건물·기둥·벽 충돌 + if (d / "objects" / "objects.json").exists(): # 차량을 함께 학습한 모델 — 차량 에셋을 각자의 궤적으로 (K: 편집) + cmd += ["--objects", str(d / "objects")] + from nusim.paths import output_dir, workspace_root + + cmd += _ibl_gltf_args(a.name, agents=a.agents) + if (sky_dir / "sky.ktx2").exists(): + cmd += ["--sky", str(sky_dir / "sky.ktx2")] + if (d / "world.vox").exists(): + cmd += ["--voxels", str(d / "world.vox")] # 라이다·레이더 (configs/sim.json sensors, 창의 '센서' 패널) + from nusim.config import sim_config, sim_config_path + + if sim_config_path().exists(): + cmd += ["--config", str(sim_config_path())] # 날씨 프리셋·하늘·3인칭 카메라·충돌 (configs/sim.json) + ego_cfg = sim_config().get("ego", {}) + ego_gltf = workspace_root() / ego_cfg.get("gltf", "assets/gltf/ego_car.glb") + ego = workspace_root() / ego_cfg.get("fallback_3dgs_asset", "outputs/assets/ego_car.ply") + if ego_gltf.exists() and "--ibl" in cmd: + cmd += ["--ego-gltf", str(ego_gltf)] # 자차 = 머티리얼 있는 차량 메시 (PBR + IBL) + if "length_m" in ego_cfg: + cmd += ["--ego-length", str(float(ego_cfg["length_m"]))] + elif ego.exists(): + cmd += ["--ego-asset", str(ego)] # 없으면 nuScenes 주차 차량 3DGS 에셋 + if a.autoshot: + cmd += ["--autoshot", str(a.autoshot)] + elif not a.replay: + cmd += ["--drive"] # 운전 모드(차 뒤 시점)로 시작. G 를 누르면 로그 재생(차에 달린 카메라 시점) + if a.weather: + cmd += ["--weather", a.weather] # 합성 날씨 시작값 — 창 안 슬라이더(F1)로 바꿀 수 있다 + os.execv(cmd[0], cmd) + + +def _cmd_render_map(a: argparse.Namespace) -> int: + import os + + from nusim.config import sim_config, sim_config_path + from nusim.paths import output_dir, workspace_root + from nusim.render.compare import viewer_binary + from nusim.render.regionmap import write_region_map + + root = Path(a.root) if a.root else output_dir() / "gs_day" + out = root / "_map" / a.location + if not a.no_sensors: # 없는 씬 것만 (--sensors: 학습된 구간이 바뀐 씬도 다시) + from nusim.render.regionmap import build_sensor_worlds + + n = build_sensor_worlds(a.location, root, log=print, stale=a.sensors) + if n: + print(f" 센서 복셀 월드 {n}개 씬 새로 만듦") + info = write_region_map(a.location, root, out, start=a.start, log=print) + if a.no_view: + return 0 + from nusim.recon.semantic import ensure_semantic + from nusim.render.regionmap import trained_scenes + + for s, segs in ([] if a.no_semantic else trained_scenes(root, a.location)): # 시맨틱 카메라(F3) 라벨 — 새로 학습된 구간만 (모델당 수 초) + ensure_semantic(s, [m for _, m in segs], log=print) + from nusim.aug.map_aug import ensure_ibl + from nusim.recon.batch import segment_dirs + + ensure_ibl(info["anchor"], segment_dirs(root, info["anchor"]), log=print) # 없으면 glTF 자차 대신 3DGS 자차(검은 덩어리)가 나온다 + cmd = [str(viewer_binary()), info["first_ply"], "--cams", str(out / "rig.txt"), "--segments", str(out / "segments.txt"), + "--lines", str(out / "map.txt"), "--drivable", str(out / "drivable.txt"), "--shot-dir", str(out / "shots")] + cmd += _ibl_gltf_args(info["anchor"]) + if Path(info["sky"]).exists(): + cmd += ["--sky", info["sky"]] + if sim_config_path().exists(): + cmd += ["--config", str(sim_config_path())] + ego_cfg = sim_config().get("ego", {}) + ego_gltf = workspace_root() / ego_cfg.get("gltf", "assets/gltf/ego_car.glb") + ego = workspace_root() / ego_cfg.get("fallback_3dgs_asset", "outputs/assets/ego_car.ply") + if ego_gltf.exists() and "--ibl" in cmd: + cmd += ["--ego-gltf", str(ego_gltf)] + if "length_m" in ego_cfg: + cmd += ["--ego-length", str(float(ego_cfg["length_m"]))] + elif ego.exists(): + cmd += ["--ego-asset", str(ego)] + if not a.replay: + cmd += ["--drive"] + cmd += a.viewer_args # nusim render map <지역> -- --autopilot ... (뷰어 옵션 그대로) + print(f" 지역 맵 {info['location']}: 씬 {info['scenes']}개 · 구간 {info['segments']}개 — 센서 복셀 월드 " + f"{info['sensor_worlds']}/{info['scenes']}씬" + + ("" if info["sensor_worlds"] == info["scenes"] else " (나머지는 센서 꺼짐 — --no-sensors 를 빼고 다시)") + + " · 장애물 지도는 아직 지역 단위로 없음 (연석 충돌만)") + os.execv(cmd[0], cmd) + + +def _cmd_render_encode(a: argparse.Namespace) -> int: + """PNG 프레임 디렉터리 (vkgs_viewer --record-screen / --record) → mp4. 왼쪽 아래에 설명 글자를 얹는다.""" + import imageio.v2 as imageio + import numpy as np + from PIL import Image, ImageDraw, ImageFont + + frames = sorted(Path(a.frames).glob("frame_*.png")) + if not frames: + raise SystemExit(f"{a.frames} 에 frame_*.png 가 없다") + font = None + for f in ("/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf", "/usr/share/fonts/truetype/nanum/NanumSquareRoundB.ttf"): + if Path(f).exists(): + font = ImageFont.truetype(f, a.font_size) + break + out = Path(a.out) + out.parent.mkdir(parents=True, exist_ok=True) + with imageio.get_writer(out, fps=a.fps, codec="libx264", quality=None, bitrate=a.bitrate, macro_block_size=1, + pixelformat="yuv420p") as w: + for fp in frames: + im = Image.open(fp).convert("RGB") + if a.width and im.width != a.width: + im = im.resize((a.width, round(im.height * a.width / im.width / 2) * 2), Image.LANCZOS) + if a.caption: + d = ImageDraw.Draw(im, "RGBA") + x0, y0, x1, y1 = d.textbbox((0, 0), a.caption, font=font) + m = 10 + d.rectangle((m, im.height - m - (y1 - y0) - 14, m + x1 - x0 + 16, im.height - m), fill=(0, 0, 0, 160)) + d.text((m + 8 - x0, im.height - m - (y1 - y0) - 7 - y0), a.caption, font=font, fill=(255, 255, 255, 255)) + w.append_data(np.asarray(im)) + if a.poster: + Image.open(frames[len(frames) // 2]).convert("RGB").save(a.poster, quality=90) + print(f" {len(frames)}프레임 → {out} ({out.stat().st_size / 2**20:.1f} MB)") + return 0 + + +def _cmd_render_video(a: argparse.Namespace) -> int: + from nusim.render.video import compose, render_frames, write_video + + d = _render_paths(a.name, getattr(a, "model", None)) + _ensure_export(a.name, d, 1) + _ensure_sky_label(a.name, d) + fdir = d / "sim_frames" + print(render_frames(d, fdir, a.frames, bev_height=a.bev_height).rstrip()) + out = d / f"{a.name}_sim.mp4" + n = write_video(fdir, out, a.name, fps=a.fps) + # 첫 프레임 정지 이미지도 남긴다 (영상을 못 여는 곳에서 보려고) + from PIL import Image + + first = sorted(fdir.glob("f*_BEV.png"))[len(sorted(fdir.glob("f*_BEV.png"))) // 2] + Image.fromarray(compose(fdir, int(first.name[1:5]), a.name)).save(d / f"{a.name}_sim.jpg", quality=92) + print(f" 영상 {n}프레임 → {out}\n 정지 이미지 → {d / (a.name + '_sim.jpg')}") + return 0 + + +def _cmd_recon_distill(a: argparse.Namespace) -> int: + from nusim.data.index import load_scene + from nusim.io.ply import read_ply + from nusim.paths import dataset, output_dir + from nusim.recon.dataset import build_views + from nusim.recon.distill import make_pseudo_views + from nusim.recon.train import TrainConfig, train + + base = Path(a.base) if a.base else output_dir() / "gs" / a.name + out = Path(a.out) if a.out else base.parent / f"{base.name}_difix" + scene, ds = _model_data(a.name, base) + if (base / "sky.pt").exists(): + # 하늘 큐브맵을 이어받는 미세조정은 아직 없다. 그냥 돌리면 하늘이 빠진 모델(하늘 구 가우시안도 없음)이 + # 생기고, 의사 뷰의 검은 하늘을 Difix 가 고친 걸 가우시안이 배워 하늘 floater 가 된다. + print(f"오류: {base} 는 하늘을 큐브맵으로 분리한 모델(sky.pt) — distill 은 아직 하늘 분리 모델을 지원하지 않습니다") + return 1 + g = read_ply(base / "point_cloud.ply") + print(f"\033[1m{a.name}\033[0m Difix 의사 정답 → 3DGS 미세조정 ({base} → {out})") + objects = _model_objects(base) + vk = _model_views(base) + views = build_views(scene, ds, g.origin, downscale=vk["downscale"], frame_stride=vk["frame_stride"], + keyframes_only=vk["keyframes_only"], objects=objects) + if a.depth_lambda > 0: # 미세조정 중에도 기하를 붙잡는다 (의사 뷰에는 깊이가 없다) + from nusim.recon.depth import lidar_depth_maps + from nusim.recon.init import InitConfig, accumulate_lidar + + import torch + + from nusim.recon.train import CameraAdjust, _refresh_depth + + pts = accumulate_lidar(scene, ds, InitConfig(objects=objects)) - g.origin + adj = CameraAdjust(6).cuda() # 렌더와 같은 (보정된) 카메라로 깊이 지도를 만든다 + if (base / "camera_adjust.pt").exists(): + adj.load_state_dict(torch.load(base / "camera_adjust.pt", weights_only=True)) + _refresh_depth(views.train, adj, pts, 4) + del adj + + def cfg_for(iters: int): + return TrainConfig(depth_lambda=a.depth_lambda, + iters=iters, sh_degree_every=0, lr_camera=0.0, + lr_means=1.6e-5, lr_means_final_ratio=0.1, # 이미 수렴한 위치를 크게 흔들지 않는다 + refine_start=500, refine_stop=iters // 2, reset_every=10**9, # 불투명도 리셋 없음 (step 0 은 train 이 건너뜀) + max_gaussians=a.max_gaussians, eval_every=a.eval_every, objects=objects, + downscale=vk["downscale"], frame_stride=vk["frame_stride"], keyframes_only=vk["keyframes_only"], + pseudo_prob=a.pseudo_prob, pseudo_weight=a.pseudo_weight) + + def pseudo_from(model: Path, shifts): + # 의사 뷰 캐시는 기반 모델·가림 설정·간격마다 따로 — 재학습한 모델에 낡은 Difix 결과를 쓰지 않게 + stamp = f"{objects}_every{a.every}_{int((model / 'point_cloud.ply').stat().st_mtime)}" + return make_pseudo_views(model, scene, views, model / "pseudo" / stamp, shifts, a.every, objects=objects, + downscale=vk["downscale"]) + + if not a.progressive: + pseudo = pseudo_from(base, tuple(float(x) for x in a.shifts.split(","))) + train(views, g, out, cfg_for(a.iters), resume_dir=base, pseudo=pseudo) + return 0 + + # 점진 증류 (Difix3D+): 가까운 이동부터. 단계마다 직전 단계 모델로 ±s 시점을 렌더 → Difix → 의사 뷰를 쌓고 짧게 + # 미세조정. 출발점이 이미 좋아져서 Difix 결과가 시점마다 일관되고, 한 번에 3 m 를 고칠 때의 흐려짐을 줄인다. + steps = [float(x) for x in a.progressive.split(",")] + pseudo, cur = [], base + for k, s in enumerate(steps): + print(f"\n ── 점진 {k + 1}/{len(steps)}: ±{s:g} m (출발 {cur})") + pseudo += pseudo_from(cur, (-s, s)) + stage = out / f"stage{k + 1}_{s:g}m" if k < len(steps) - 1 else out + g_cur = read_ply(cur / "point_cloud.ply") + train(views, g_cur, stage, cfg_for(a.iters_per_stage), resume_dir=cur, pseudo=pseudo) + cur = stage + return 0 + + +def _cmd_asset_scan(a: argparse.Namespace) -> int: + from nusim.data.index import iter_scenes + from nusim.recon.asset import scan_parked_cars + + rows = scan_parked_cars(iter_scenes(), max_dist=a.max_dist) + print(f"{'방위(/8)':>8} {'최소거리':>8} {'scene':<12} {'inst':>4} 크기 (w,l,h)") + for r in rows[: a.limit]: + print(f"{r[0]:>8} {r[1]:>7.1f}m {r[2]:<12} {r[3]:>4} {r[4]}") + return 0 + + +def _cmd_asset_extract(a: argparse.Namespace) -> int: + from nusim.data.index import load_scene + from nusim.io.ply import read_ply, write_ply + from nusim.paths import output_dir + from nusim.recon.asset import extract_vehicle + + model = Path(a.model) if a.model else output_dir() / "gs" / f"{a.name}_depth" + g = extract_vehicle(read_ply(model / "point_cloud.ply"), load_scene(a.name), a.instance, mirror=not a.no_mirror) + out = Path(a.out) if a.out else output_dir() / "assets" / f"car_{a.name}_{a.instance}.ply" + write_ply(out, g) + print(f" 에셋 가우시안 {len(g):,}개 → {out}") + if a.as_ego: + ego = output_dir() / "assets" / "ego_car.ply" + ego.unlink(missing_ok=True) + ego.symlink_to(out.name) + print(f" 자차 에셋으로 지정 → {ego} (nusim render view 가 자동으로 쓴다)") + return 0 + + +def _cmd_render_ibl(a: argparse.Namespace) -> int: + from nusim.data.index import load_scene + from nusim.paths import output_dir + from nusim.render.ibl import bake_ibl + + d = _render_paths(a.name, a.model) + out = output_dir() / "ibl" / a.name + print(f"\033[1m{a.name}\033[0m IBL 굽기 (모델 {d})") + bake_ibl(load_scene(a.name), d, out, frame=a.frame, size=a.size, samples=a.samples, fill=a.fill) + return 0 + + +def _cmd_doctor(a: argparse.Namespace) -> int: + from nusim.cli.doctor import main as doctor_main + return doctor_main() + + + +# ------------------------------------------------------------------ closed-loop 평가 (nusim.sim.closed_loop, HUGSIM 식) +def _cmd_sim_run(a: argparse.Namespace) -> int: + from nusim.paths import output_dir + from nusim.sim.closed_loop import run_episode + from nusim.sim.policies import make_policy + from nusim.sim.scenario import Scenario, closed_loop_config + + scn = Scenario.load(a.scenario) + over = dict(scn.overrides) + if a.size: + w, h = a.size.lower().split("x") + over.setdefault("render", {}).update(width=int(w), height=int(h)) + if a.max_duration: + scn.max_duration_s = a.max_duration + cfg = closed_loop_config(over) + out = Path(a.out) if a.out else output_dir() / "closed_loop" / scn.name / a.policy + print(f" 시나리오 {scn.name} ({scn.difficulty}, {scn.scene}) · 정책 {a.policy} → {out}") + res = run_episode(scn, make_policy(a.policy), out, cfg, save_front=not a.no_front, video=not a.no_video) + return 0 if res["hd_score"] >= 0 else 1 + + +def _cmd_sim_score(a: argparse.Namespace) -> int: + import json + + from nusim.sim.hdscore import format_score, score_episode + + for ep in a.episodes: + res = score_episode(Path(ep)) + print(f" {ep} [{res.get('scenario')} · {res.get('policy')} · {res.get('termination')}]") + print(f" {format_score(res)}") + if a.verbose: + for p in res["per_step"]: + print(f" t={p['t']:5.1f} NC {p['nc']:.1f} DAC {p['dac']:.0f} TTC {p['ttc']:.0f} COM {p['com']:.0f}" + f" → {p['hd']:.3f}" + (f" (승차감: {','.join(p['com_fail'])})" if p["com_fail"] else "")) + if a.json: + print(json.dumps({k: v for k, v in res.items() if k != "per_step"}, indent=1)) + return 0 + + +def _cmd_sim_make_scenarios(a: argparse.Namespace) -> int: + import numpy as np + + from nusim.data.index import load_scene + from nusim.paths import output_dir, workspace_root + from nusim.recon.batch import segment_dirs + from nusim.sim.scenario import closed_loop_config, scenarios_dir, write_scenario + + cfg = closed_loop_config()["scenarios"] + root = Path(a.root) if a.root else output_dir() / "gs_day" + names = a.scenes or sorted(p.name for p in root.iterdir() if p.is_dir() and p.name.startswith("scene-")) + out_dir = Path(a.out) if a.out else scenarios_dir() + n_ok = 0 + for name in names: + segs = segment_dirs(root, name) + if not segs or len(segs) < segs[0][0].get("n", 1): + print(f" {name}: 건너뜀 — 구간 {len(segs)}/{segs[0][0].get('n', 1) if segs else '?'}개만 학습됨") + continue + fr = load_scene(name).frames["CAM_FRONT"] + xy = np.array([f.ego_translation[:2] for f in fr]) + ts = np.array([f.timestamp for f in fr]) * 1e-6 + dist = float(np.linalg.norm(np.diff(xy, axis=0), axis=1).sum()) + route_m = float(a.route_m if a.route_m is not None else cfg["route_m"]) + use_m = min(dist, route_m) if route_m > 0 else dist + if use_m < cfg["min_route_m"]: + print(f" {name}: 건너뜀 — 경로 {dist:.1f} m < {cfg['min_route_m']} m (정차 씬)") + continue + dur = float(ts[-1] - ts[0]) * (use_m / max(dist, 1e-6)) + model = root / name + try: + model = model.relative_to(workspace_root()) + except ValueError: + pass + d = {"name": f"{name}_easy", "difficulty": "easy", "scene": name, "model": str(model), + "start": {"frame": 0, "speed": cfg["start_speed"]}, + "route": {"to_frame": None, "max_m": route_m}, + "max_duration_s": round(dur * cfg["duration_factor"] + cfg["duration_pad_s"], 1), + "actors": [], "overrides": {}, + "info": {"route_m": round(use_m, 1), "log_duration_s": round(dur, 1), "segments": len(segs)}} + write_scenario(out_dir / f"{name}_easy.json", d) + n_ok += 1 + print(f" {name}_easy: 경로 {use_m:.0f} m, 기록 {dur:.1f}초, 구간 {len(segs)}개") + print(f" 시나리오 {n_ok}개 → {out_dir}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="nusim", description=__doc__.splitlines()[0]) + sub = p.add_subparsers(dest="cmd", required=True) + + idx = sub.add_parser("index", help="씬 인덱스 생성/조회").add_subparsers(dest="sub", required=True) + + b = idx.add_parser("build", help="원본 테이블을 훑어 씬 인덱스를 캐시에 만든다 (약 40초)") + b.add_argument("--force", action="store_true", help="이미 있어도 다시 만든다") + b.add_argument("--no-sweeps", action="store_true", + help="키프레임(2 Hz)만 담는다. 12배 작지만 재구성 품질이 떨어진다") + b.add_argument("--locations", nargs="+", metavar="LOC", + help="기본값은 싱가포르 3종. 예: --locations boston-seaport") + b.set_defaults(func=_cmd_index_build) + + l = idx.add_parser("list", help="인덱싱된 씬 목록") + l.add_argument("--location", help="singapore-onenorth 등으로 필터") + l.add_argument("--grep", help="이름/설명에 이 문자열이 든 씬만") + l.add_argument("--limit", type=int, help="상위 N개만") + l.set_defaults(func=_cmd_index_list) + + s = sub.add_parser("scene", help="씬 상세").add_subparsers(dest="sub", required=True) + sh = s.add_parser("show", help="씬 하나의 카메라/프레임 요약") + sh.add_argument("name", help="예: scene-0003") + sh.set_defaults(func=_cmd_scene_show) + + rn = sub.add_parser("run", help="작업 정의(configs/jobs/<이름>.json) 실행 — 여러 단계를 순서대로, 끝난 단계는 건너뛴다") + rn.add_argument("job", help="작업 이름 또는 JSON 경로") + rn.add_argument("--dry-run", action="store_true", help="실행하지 않고 펼친 단계만 보여 준다") + rn.set_defaults(func=_cmd_run) + + r = sub.add_parser("recon", help="3DGS 재구성").add_subparsers(dest="sub", required=True) + ri = r.add_parser("init", help="LIDAR_TOP → 초기 가우시안 (outputs/gs//init.ply)") + ri.add_argument("name") + ri.add_argument("--voxel", type=float, default=0.15, help="복셀 크기 m (기본 0.15)") + ri.add_argument("--max-points", type=int, default=1_500_000) + ri.set_defaults(func=_cmd_recon_init) + + rt = r.add_parser("train", help="gsplat 학습 → point_cloud.ply + report.json") + p._nusim_train = rt + rt.add_argument("name") + rt.add_argument("--iters", type=int, default=60_000, + help="기본 6만. 씬당 뷰가 1,200장 남짓이라 3만이면 뷰당 25회뿐 — 덜 학습된다") + rt.add_argument("--downscale", type=int, default=2, help="이미지 축소 비율 (기본 2 → 800x450)") + rt.add_argument("--stride", type=int, default=1, help="프레임 간격 (2 면 약 6 Hz)") + rt.add_argument("--keyframes-only", action="store_true", help="2 Hz 키프레임만") + rt.add_argument("--image-root", default=None, + help="카메라 이미지를 이 루트에서 (원본과 같은 상대 경로) — nusim aug translate 결과 등") + rt.add_argument("--time-window", default=None, metavar="T0:T1", + help="씬 시작부터 T0~T1 초만 학습 (빠른 씬을 구간별로 나눌 때)") + rt.add_argument("--max-gaussians", type=int, default=2_000_000) + rt.add_argument("--eval-every", type=int, default=5000) + rt.add_argument("--refine-stop", type=int, help="densify 종료 스텝 (기본 min(15000, iters/2))") + rt.add_argument("--absgrad", action="store_true", help="절댓값 gradient 로 densify (잎·세부에 유리)") + rt.add_argument("--grow-grad2d", type=float, help="densify 임계값 (기본 0.0002, absgrad 면 0.0008)") + rt.add_argument("--depth-lambda", type=float, default=0.0, help="라이다 깊이 감독 가중치 (0 이면 끔)") + rt.add_argument("--strategy", choices=["default", "mcmc"], default="default", + help="densify 전략. mcmc = 3DGS-MCMC (cap = --max-gaussians, 불투명도·스케일 정규화 0.01)") + rt.add_argument("--sky", action="store_true", help="하늘을 학습 가능한 큐브맵으로 분리 (하늘 마스크 BCE)") + rt.add_argument("--no-sky", dest="sky", action="store_false", help="하늘 분리 끄기 (프리셋이 켠 것을 끌 때)") + rt.add_argument("--preset", help="학습 프리셋 (configs/train/<이름>.json 또는 경로) — 명령줄 값이 우선") + rt.add_argument("--objects", choices=("dynamic", "agents", "vehicles"), default="dynamic", + help="학습에서 지울 객체. dynamic: 움직이는 것만 (주차 차량은 배경), " + "agents: 차량·사람·자전거 전부 — 주행 맵만 남기는 환경 전용 재구성, " + "vehicles: 차량은 객체 가우시안으로 함께 학습(에셋으로 내보냄), 사람·자전거만 지움") + rt.add_argument("--opacity-reg", type=float, help="불투명도 정규화 (mcmc 기본 0.01)") + rt.add_argument("--aniso-reg", type=float, default=0.0, help="바늘 억제 가중치 (가장 긴 축/두 번째 축 비 벌점, 0 = 끔)") + rt.add_argument("--aniso-max", type=float, default=6.0, help="바늘 억제가 시작되는 축 비") + rt.add_argument("--ground-lambda", type=float, default=0.0, + help="지면 모델 (HUGSIM 다중 평면): 노면 가우시안을 원판으로 고정 + 칸별 높이 분산 가중치 (0 이면 끔)") + rt.add_argument("--scale-reg", type=float, help="스케일 정규화 (mcmc 기본 0.01)") + rt.add_argument("--depth-scale", type=int, default=2, + help="깊이 지도 축소 비율 (sweep 누적이면 1/2 에서도 79%% 채워진다)") + rt.add_argument("--no-camera-adjust", action="store_true", help="카메라 외부 파라미터 보정 끄기") + rt.add_argument("--out", help="결과 디렉터리 (기본 outputs/gs/). 실험을 나란히 돌릴 때") + rt.add_argument("--tile", help="여러 주행을 한 모델로 (configs/tiles/<이름>.json, recon.tile). name 대신 타일의 anchor") + rt.set_defaults(func=_cmd_recon_train) + + rte = r.add_parser("tile-eval", help="타일에서 뺀 주행(옆 차선)의 실제 사진으로 모델들을 비교") + rte.add_argument("tile") + rte.add_argument("--dir", action="append", required=True, help="모델 디렉터리 (여러 번)") + rte.add_argument("--stride", type=int, default=4, help="뺀 주행의 프레임 간격") + rte.add_argument("--out", help="기본 outputs/tiles/<타일>") + rte.set_defaults(func=_cmd_recon_tile_eval) + + rds = r.add_parser("distill", help="Difix 로 고친 새 시점(차선 이동)을 의사 정답으로 3DGS 미세조정") + rds.add_argument("name") + rds.add_argument("--shifts", default="-3,-1.5,1.5,3", help="자차 좌우 이동량 m (쉼표)") + rds.add_argument("--every", type=int, default=6, help="카메라별 학습 프레임 간격") + rds.add_argument("--iters", type=int, default=15000) + rds.add_argument("--pseudo-prob", type=float, default=0.3) + rds.add_argument("--pseudo-weight", type=float, default=0.5) + rds.add_argument("--max-gaussians", type=int, default=2_600_000) + rds.add_argument("--eval-every", type=int, default=5000) + rds.add_argument("--base", help="출발 모델 디렉터리 (기본 outputs/gs/)") + rds.add_argument("--depth-lambda", type=float, default=0.0, help="라이다 깊이 감독 유지 가중치") + rds.add_argument("--progressive", help="점진 증류: 이동량 단계 (쉼표, m) — 예 0.5,1,2,3. 주면 --shifts·--iters 대신") + rds.add_argument("--iters-per-stage", type=int, default=3000, help="점진 증류 단계당 미세조정 스텝") + rds.add_argument("--out", help="기본 _difix") + rds.set_defaults(func=_cmd_recon_distill) + + rb = r.add_parser("batch", help="여러 씬 일괄 재구성 (기본: 주간 씬 전체 → outputs/gs_day/, 이어 하기 가능)") + rb.add_argument("--scenes", help="쉼표로 구분한 씬 이름 (기본: 설명에 night/rain 이 없는 씬 전부)") + rb.add_argument("--location", help="singapore-onenorth 등으로 제한") + rb.add_argument("--limit", type=int, help="앞에서 N 씬만") + rb.add_argument("--order", choices=["location", "coverage"], default="location", + help="location: 지역·씬 번호 순 (기본). coverage: 새 도로를 많이 덮는 씬부터 — 맵이 빨리 채워진다") + rb.add_argument("--preset", default="day_map", help="학습 프리셋 (configs/train/<이름>.json)") + rb.add_argument("--iters", type=int, help="프리셋의 iters 를 덮어쓴다") + rb.add_argument("--root", help="결과 루트 (기본 outputs/gs_day)") + rb.add_argument("--no-sky", action="store_true", help="프리셋과 상관없이 하늘 분리 없이") + rb.add_argument("--segment-m", type=float, default=60.0, + help="구간 길이 상한 (자차 이동 m). 더 긴 씬은 같은 길이 구간으로 나눠 따로 학습 (기본 60)") + rb.add_argument("--overlap-m", type=float, default=10.0, help="이웃 구간 겹침 (m, 기본 10)") + rb.add_argument("--after", action="append", metavar="CMD", + help="씬의 구간이 다 끝나면 돌릴 nusim 명령 ({scene}·{root} 치환, 여러 번). 예: 'aug map {scene} --root {root}'") + rb.add_argument("--keep-init", action="store_true", help="init.ply 를 지우지 않는다 (기본: 학습 뒤 지움, 디스크 절약)") + rb.set_defaults(func=_cmd_recon_batch) + + rc = r.add_parser("cycle", help="사이클 렌더 벤치마크: 옆으로 옮긴 렌더로 모델을 다시 학습해 원래 시점(정답 사진)에서 잰다") + rc.add_argument("name") + rc.add_argument("--base", help="실제 사진으로 학습한 모델 (기본 outputs/gs_day/)") + rc.add_argument("--iters", type=int, default=15_000) + rc.add_argument("--shift", type=float, default=1.0, help="옆으로 옮기는 거리 m (기본 1, 논문과 같음)") + rc.set_defaults(func=_cmd_recon_cycle) + rp = r.add_parser("prune", help="floater 정리: 라이다가 비어 있다고 본 공간에 떠 있는 가우시안을 지운다") + rp.add_argument("name") + rp.add_argument("--dir", help="모델 디렉터리 (기본: render 가 쓰는 모델)") + rp.add_argument("--out", help="결과 (기본 _pruned)") + rp.add_argument("--margin", type=float, default=0.15, help="라이다 표면보다 이 비율 이상 앞이면 위반") + rp.add_argument("--ratio", type=float, default=0.5, help="위반 비율이 이보다 크면 지움") + rp.add_argument("--min-obs", type=int, default=3, help="판정에 필요한 최소 관측 횟수") + rp.set_defaults(func=_cmd_recon_prune) + rsl = r.add_parser("skylabel", help="하늘 가우시안 표시: 하늘 마스크에 떨어지는 가우시안 (밤에 하늘이 하얗게 남는 문제)") + rsl.add_argument("name") + rsl.add_argument("--dir", help="모델 디렉터리 (기본: render 가 쓰는 모델)") + rsl.add_argument("--ratio", type=float, default=0.4, help="하늘 픽셀 비율이 이보다 크면 하늘") + rsl.add_argument("--min-obs", type=int, default=3, help="판정에 필요한 최소 관측 횟수") + rsl.set_defaults(func=_cmd_recon_skylabel) + rse = r.add_parser("semantic", help="가우시안 시맨틱 라벨 (lidarseg 다수결) → 모델 옆 semantic.rgba — 뷰어의 시맨틱 카메라") + rse.add_argument("names", nargs="*", help="씬 (구간 모델이면 구간 전부)") + rse.add_argument("--location", help="이 지역의 학습된 씬 전부 (singapore-onenorth 등)") + rse.add_argument("--root", help="일괄 재구성 루트 (기본 outputs/gs_day)") + rse.add_argument("--dir", help="구간 모델이 아닌 씬의 모델 디렉터리 (기본: render 가 쓰는 모델)") + rse.add_argument("--force", action="store_true", help="있어도 다시") + rse.set_defaults(func=_cmd_recon_semantic) + rbn = r.add_parser("bench", help="벤치 세트(/bench)의 예측을 정답과 비교 (PSNR·SSIM·LPIPS·DISTS)") + rbn.add_argument("bench") + rbn.add_argument("--pred", default="render,base", help="비교할 접미사들 (render = 사이클 렌더, base = 원 모델, " + "difix = Difix 전체 보정, difixm = 신뢰도 마스크 블렌딩)") + rbn.add_argument("--run-difix", action="store_true", help="먼저 Difix 를 돌려 *_difix / *_difixm 을 만든다") + rbn.set_defaults(func=_cmd_recon_bench) + + re_ = r.add_parser("eval", help="학습된 ply 를 hold-out 뷰로 다시 평가 (정적/전체 PSNR)") + re_.add_argument("name") + re_.add_argument("--dir", help="point_cloud.ply 와 color_correction.pt 가 있는 곳 (기본 outputs/gs/)") + re_.add_argument("--downscale", type=int, default=None, help="기본: 학습 때 값 (report.json)") + re_.add_argument("--geom", action="store_true", + help="기하·floater 지표도: 정적/구멍(동적 마스크) 깊이 오차, 하늘 α (nusim.recon.metrics)") + re_.add_argument("--lpips", action="store_true", help="LPIPS (AlexNet) 도 — 전체 영상 · 정적 영역") + re_.set_defaults(func=_cmd_recon_eval) + + rd = sub.add_parser("render", help="Vulkan 렌더러 (src/vkgs) 연동").add_subparsers(dest="sub", required=True) + ex = rd.add_parser("export", help="카메라 리그 + 맵 선 파일 → outputs/gs//{rig,map}.txt") + ex.add_argument("name") + ex.add_argument("--model", help="모델 디렉터리 (기본 outputs/gs/_depth, 없으면 )") + ex.add_argument("--downscale", type=int, default=1, help="리그 해상도 축소 (기본 1 = 1600x900)") + ex.add_argument("--margin", type=float, default=60.0, help="궤적 주변 맵 범위 m") + ex.set_defaults(func=_cmd_render_export) + cp = rd.add_parser("compare", help="평가 뷰에서 Vulkan 렌더 vs gsplat 렌더 픽셀 비교") + cp.add_argument("name") + cp.add_argument("--model", help="모델 디렉터리 (기본 outputs/gs/_depth, 없으면 )") + cp.add_argument("-n", type=int, default=24, help="비교할 뷰 수") + cp.set_defaults(func=_cmd_render_compare) + vw = rd.add_parser("view", help="창 모드 뷰어 (자유 카메라 / 6캠 추종 / 궤적 재생 / BEV / 맵)") + vw.add_argument("name") + vw.add_argument("--model", help="모델 디렉터리 (기본 outputs/gs/_depth, 없으면 )") + vw.add_argument("--downscale", type=int, default=1) + vw.add_argument("--autoshot", type=int, default=0, help="N 프레임 뒤 스크린샷 찍고 종료 (점검용)") + vw.add_argument("--weather", help="합성 날씨 시작값: clear|overcast|dusk|night|rain|heavy_rain|fog|night_rain " + "또는 daylight=0.3,rain=0.5,... (창 안 슬라이더로도 조절, F1 패널)") + vw.add_argument("--replay", action="store_true", help="로그 재생 모드(차에 달린 CAM_FRONT 시점)로 시작 (기본: 운전 모드, 차 뒤 시점)") + vw.add_argument("--agents", action="store_true", + help="nuScenes 로그의 움직이는 차 궤적에 glTF 차량을 넣는다 (기본: 환경만)") + vw.set_defaults(func=_cmd_render_view) + rm = rd.add_parser("map", help="지역 맵: 학습된 씬 구간들을 이어 한 공간에서 주행 (singapore-onenorth 등)") + rm.add_argument("location", help="singapore-onenorth | singapore-queenstown | singapore-hollandvillage") + rm.add_argument("--start", help="출발 씬 (기본: 구간이 가장 많은 씬)") + rm.add_argument("--root", help="일괄 재구성 루트 (기본 outputs/gs_day)") + rm.add_argument("--replay", action="store_true", help="출발 씬의 로그 재생 모드로 시작") + rm.add_argument("--sensors", action="store_true", + help="학습된 구간이 바뀐 씬의 라이다·레이더 복셀 월드도 다시 만든다 (없는 씬 것은 기본으로 만든다, 씬당 1~2분)") + rm.add_argument("--no-sensors", action="store_true", help="없는 복셀 월드를 만들지 않는다 (그 씬은 센서 꺼짐)") + rm.add_argument("--no-view", action="store_true", help="파일만 만들고 창은 띄우지 않는다") + rm.add_argument("--no-semantic", action="store_true", + help="없는 시맨틱 라벨을 만들지 않는다 (그 구간은 F3 시맨틱 카메라가 비어 보인다, 나중에 nusim recon semantic)") + rm.set_defaults(viewer_args=[]) # '--' 뒤는 vkgs_viewer 옵션 그대로 (main 이 떼어 둔다): -- --autopilot --show-sensors + rm.set_defaults(func=_cmd_render_map) + + ve = rd.add_parser("encode", help="PNG 프레임 (vkgs_viewer --record-screen) → mp4, 설명 글자 얹기") + ve.add_argument("frames", help="frame_*.png 디렉터리") + ve.add_argument("out", help="출력 .mp4") + ve.add_argument("--fps", type=float, default=20.0, help="녹화는 고정 50 ms 스텝 = 20 fps") + ve.add_argument("--width", type=int, default=1280, help="가로 픽셀로 줄인다 (0 = 그대로)") + ve.add_argument("--bitrate", default="3M", help="libx264 비트레이트 (발표자료에 넣을 크기: 3M × 20초 ≈ 7.5 MB)") + ve.add_argument("--caption", default="", help="왼쪽 아래 글자") + ve.add_argument("--font-size", type=int, default=24) + ve.add_argument("--poster", help="가운데 프레임을 이 jpg 로 (발표자료 표지 화면)") + ve.set_defaults(func=_cmd_render_encode) + vd = rd.add_parser("video", help="6캠 + BEV(맵 오버레이) 재생 영상 → outputs/gs//_sim.mp4") + vd.add_argument("name") + vd.add_argument("--model", help="모델 디렉터리 (기본 outputs/gs/_depth, 없으면 )") + vd.add_argument("--frames", help="프레임 범위 A:B (기본 전체)") + vd.add_argument("--fps", type=float, default=12.0, help="기본 12 (nuScenes 카메라 약 12 Hz)") + vd.add_argument("--bev-height", type=float, default=70.0) + vd.set_defaults(func=_cmd_render_video) + + asp = sub.add_parser("asset", help="nuScenes 실제 차량 → 3DGS 에셋").add_subparsers(dest="sub", required=True) + sc_ = asp.add_parser("scan", help="자차가 가까이 지나가며 여러 방위에서 본 주차 차량 찾기 (주간 씬)") + sc_.add_argument("--max-dist", type=float, default=5.0) + sc_.add_argument("--limit", type=int, default=15) + sc_.set_defaults(func=_cmd_asset_scan) + xt = asp.add_parser("extract", help="학습된 씬에서 주차 차량을 잘라 박스 로컬 3DGS 에셋으로") + xt.add_argument("name", help="씬 (예: scene-0916)") + xt.add_argument("--instance", type=int, required=True) + xt.add_argument("--model", help="씬 모델 디렉터리 (기본 outputs/gs/_depth)") + xt.add_argument("--no-mirror", action="store_true", help="좌우 대칭 복사 끄기") + xt.add_argument("--out") + xt.add_argument("--as-ego", action="store_true", help="outputs/assets/ego_car.ply 로 지정") + xt.set_defaults(func=_cmd_asset_extract) + + ib = rd.add_parser("ibl", help="6캠 파노라마 → HDR 큐브맵 → prefiltered diffuse/specular + BRDF LUT → BC6H KTX2") + ib.add_argument("name") + ib.add_argument("--model") + ib.add_argument("--frame", type=int, help="CAM_FRONT 프레임 (기본 가운데)") + ib.add_argument("--size", type=int, default=512) + ib.add_argument("--samples", type=int, default=512, help="GGX 샘플 수") + ib.add_argument("--fill", choices=["extrap", "gs"], default="extrap", + help="사진이 없는 위·아래 채움: extrap = 띠 가장자리 색 외삽 (기본), gs = 3DGS 렌더") + ib.set_defaults(func=_cmd_render_ibl) + + ag = sub.add_parser("aug", help="데이터셋 다양성 증강").add_subparsers(dest="sub", required=True) + agr = ag.add_parser("rig", help="재구성 장면을 6캠 시점(장착 위치·자세)을 바꿔 다시 렌더 → outputs/aug/rig") + agr.add_argument("name") + agr.add_argument("--only", help="변형 이름들 (쉼표). 기본: 전부") + agr.add_argument("--variants", help="변형 정의 JSON (기본 configs/aug/rig_variants.json)") + agr.add_argument("--model", help="모델 디렉터리 (기본: render 가 쓰는 모델)") + agr.add_argument("--agents", action="store_true", help="지운 차량을 glTF 로 다시 넣는다 (환경 전용 모델일 때)") + agr.add_argument("--out", help="기본 outputs/aug/rig") + agr.set_defaults(func=_cmd_aug_rig) + agm = ag.add_parser("map", help="일괄 재구성(구간 모델) 씬을 리그 변형으로 다시 렌더 → outputs/aug/map") + agm.add_argument("name") + agm.add_argument("--root", help="일괄 재구성 루트 (기본 outputs/gs_day)") + agm.add_argument("--only", help="변형 이름들 (쉼표). 기본: 날씨 없는 변형 전부") + agm.add_argument("--variants", help="변형 정의 JSON (기본 configs/aug/rig_variants.json)") + agm.add_argument("--keyframes-only", action="store_true", help="키프레임(2 Hz sample)만") + agm.add_argument("--agents", action="store_true", help="지운 차량을 glTF 로 다시 넣는다 (IBL 이 없으면 굽는다)") + agm.add_argument("--out", help="기본 outputs/aug/map") + agm.set_defaults(func=_cmd_aug_map) + agx = ag.add_parser("artifixer", help="ArtiFixer 확산 보정 + 차선 후처리 증강 (configs/aug/artifixer.json) → outputs/aug/aug_artfixer/<씬>_lanefix") + agx.add_argument("names", nargs="*", help="씬 (기본: 학습 · aug map 이 끝난 씬 전부, 끝난 순서대로)") + agx.add_argument("--cams", help="카메라들 (쉼표, 기본 설정 cams = CAM_FRONT)") + agx.add_argument("--root", help="일괄 재구성 루트 (기본 outputs/gs_day)") + agx.add_argument("--limit", type=int, help="안 끝난 씬 중 앞에서 N개만") + agx.add_argument("--force", action="store_true", help="끝났어도 다시") + agx.set_defaults(func=_cmd_aug_artifixer) + agt = ag.add_parser("translate", help="밤·비 영상 → 낮 (CycleGAN-Turbo) → outputs/aug/translate/<모델>/ (원본과 같은 상대 경로)") + agt.add_argument("names", nargs="+") + agt.add_argument("--models", default="night_to_day", + help="차례로 적용할 변환 (쉼표): night_to_day, rainy_to_clear — 예: rainy_to_clear,night_to_day") + agt.add_argument("--size", default="912x512", help="모델 입력 크기 (8의 배수). 원본 비율 16:9") + agt.add_argument("--stride", type=int, default=1) + agt.add_argument("--keyframes-only", action="store_true") + agt.add_argument("--out", help="기본 outputs/aug/translate") + agt.set_defaults(func=_cmd_aug_translate) + sm = sub.add_parser("sim", help="closed-loop 평가 (HUGSIM 식: 시나리오 · 정책 · HD-Score)").add_subparsers(dest="sub", required=True) + smr = sm.add_parser("run", help="시나리오 하나를 정책으로 끝까지 (창 없음, vkgs_viewer --serve) → 에피소드 로그 + HD-Score") + smr.add_argument("scenario", help="configs/scenarios/<이름>.json 의 이름 또는 경로") + smr.add_argument("--policy", default="replay", help="replay (기록 궤적 전문가) | constant (일정 속도 직진)") + smr.add_argument("--out", help="기본 outputs/closed_loop/<시나리오>/<정책>") + smr.add_argument("--size", help="관측 해상도 WxH (기본 configs/closed_loop.json render)") + smr.add_argument("--max-duration", type=float, help="제한 시간 덮어쓰기 (초)") + smr.add_argument("--no-front", action="store_true", help="전방 카메라 jpg 를 남기지 않는다") + smr.add_argument("--no-video", action="store_true", help="front.mp4 를 만들지 않는다") + smr.set_defaults(func=_cmd_sim_run) + sms = sm.add_parser("score", help="에피소드 로그 → NC/DAC/TTC/COM/Rc/HD-Score") + sms.add_argument("episodes", nargs="+", help="에피소드 디렉터리 (episode.json + steps.csv)") + sms.add_argument("-v", "--verbose", action="store_true", help="결정 시점마다") + sms.add_argument("--json", action="store_true") + sms.set_defaults(func=_cmd_sim_score) + smm = sm.add_parser("make-scenarios", help="일괄 재구성 씬(구간이 다 끝난 것)마다 Easy 시나리오 → configs/scenarios/") + smm.add_argument("scenes", nargs="*", help="기본: --root 의 모든 씬") + smm.add_argument("--root", help="기본 outputs/gs_day") + smm.add_argument("--route-m", type=float, help="경로 길이 상한 (기본 configs/closed_loop.json scenarios.route_m, 0 = 끝까지)") + smm.add_argument("--out", help="기본 configs/scenarios") + smm.set_defaults(func=_cmd_sim_make_scenarios) + d = sub.add_parser("doctor", help="환경 진단 (vkgs-doctor 와 동일)") + d.set_defaults(func=_cmd_doctor) + + return p + + +def _preset_defaults(argv: list[str] | None) -> dict: + """`recon train --preset X` 의 프리셋 값 (configs/train/X.json). 명령줄 값이 우선하도록 기본값으로만 쓴다.""" + av = sys.argv[1:] if argv is None else argv + if "--preset" not in av or av[:2] != ["recon", "train"]: + return {} + from nusim.config import train_preset + + return train_preset(av[av.index("--preset") + 1]) + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + preset = _preset_defaults(argv) + if preset: + rt = parser._nusim_train # recon train 하위 파서 + known = {a.dest for a in rt._actions} + bad = sorted(set(preset) - known) + if bad: + print(f"\033[1;31m오류:\033[0m 프리셋에 모르는 키: {bad} (nusim recon train --help)", file=sys.stderr) + return 1 + rt.set_defaults(**preset) + argv = list(sys.argv[1:] if argv is None else argv) + tail = [] + if "--" in argv and argv[:2] == ["render", "map"]: # render map 의 '--' 뒤 = 뷰어 옵션 + k = argv.index("--") + argv, tail = argv[:k], argv[k + 1:] + args = parser.parse_args(argv) + if tail: + args.viewer_args = tail + try: + from nusim.log import setup + from nusim.paths import output_dir + + tag = "_".join(x for x in (args.cmd, getattr(args, "sub", None)) if x) + path = setup(output_dir() / "logs", tag=tag) + LOG.debug("명령 %s (로그 %s)", " ".join(sys.argv), path) + except Exception: # 로그 설정 실패가 명령을 막으면 안 된다 (예: outputs 쓰기 불가) + pass + try: + return args.func(args) + except (FileNotFoundError, RuntimeError, ValueError) as e: + print(f"\033[1;31m오류:\033[0m {e}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + return 130 + except BrokenPipeError: # `nusim index list | head` 처럼 파이프가 먼저 닫힌 경우 + sys.stderr.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/nusim/nusim/config.py b/src/nusim/nusim/config.py new file mode 100644 index 0000000000000000000000000000000000000000..b949c65ac09722e701467c72840a8756dba9acc6 --- /dev/null +++ b/src/nusim/nusim/config.py @@ -0,0 +1,57 @@ +"""설정 파일 (configs/*.json) — 코드에 박아 두던 조정값을 한곳에 모은다. + + configs/sim.json 시뮬레이터(뷰어): 날씨 프리셋, 하늘, 3인칭 카메라, 충돌, 자차 (viewer.cpp 도 읽는다) + configs/train/<이름>.json 학습 프리셋 — `nusim recon train --preset <이름|경로>` (명령줄 값이 우선) + configs/jobs/<이름>.json 작업 정의 — `nusim run <이름|경로>` (nusim.jobs) + +JSON 의 "_doc" 키는 설명용이라 무시한다. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path + +from nusim.paths import workspace_root + + +def configs_dir() -> Path: + return workspace_root() / "configs" + + +def resolve(kind: str, name_or_path: str) -> Path: + """'day_map' → configs//day_map.json, 경로면 그대로.""" + p = Path(name_or_path) + if p.suffix == ".json" or p.exists(): + return p if p.is_absolute() or p.exists() else workspace_root() / p + return configs_dir() / kind / f"{name_or_path}.json" + + +def load(path: Path) -> dict: + if not path.exists(): + raise FileNotFoundError(f"설정 파일이 없습니다: {path}") + return _strip_doc(json.loads(path.read_text())) + + +def _strip_doc(x): + if isinstance(x, dict): + return {k: _strip_doc(v) for k, v in x.items() if not k.startswith("_doc")} + if isinstance(x, list): + return [_strip_doc(v) for v in x] + return x + + +def sim_config_path() -> Path: + return configs_dir() / "sim.json" + + +@lru_cache(maxsize=1) +def sim_config() -> dict: + p = sim_config_path() + return load(p) if p.exists() else {} + + +def train_preset(name_or_path: str) -> dict: + """학습 프리셋 → argparse dest 이름의 기본값 dict (예: {"iters": 15000, "strategy": "mcmc", ...}).""" + return load(resolve("train", name_or_path)) diff --git a/src/nusim/nusim/data/__init__.py b/src/nusim/nusim/data/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3317c7b8c7a4605800b32bd5d1792251adb4f94c --- /dev/null +++ b/src/nusim/nusim/data/__init__.py @@ -0,0 +1,5 @@ +"""nuScenes 읽기 계층 — 씬 인덱스, 카메라 리그, ego 궤적.""" + +from nusim.data.index import SceneIndex, build_index, load_catalog, load_scene + +__all__ = ["SceneIndex", "build_index", "load_catalog", "load_scene"] diff --git a/src/nusim/nusim/data/index.py b/src/nusim/nusim/data/index.py new file mode 100644 index 0000000000000000000000000000000000000000..8541aeea45d49442e5b76cfeb606ab46bd38bcca --- /dev/null +++ b/src/nusim/nusim/data/index.py @@ -0,0 +1,459 @@ +"""싱가포르 씬 인덱스 — 원본 테이블을 한 번만 훑어 캐시로 굳힌다. + +왜 이 계층이 필요한가: + nuscenes-devkit 의 NuScenes() 는 모든 테이블을 통째로 메모리에 올린다 + (trainval 기준 2~3분, 10 GB 이상). 파일 목록 하나 보자고 매번 낼 비용이 아니다. + 게다가 원본은 NFS 위에 있어 반복 접근이 특히 비싸다. + + 여기서는 테이블을 스트리밍으로 딱 한 번 훑어 우리가 쓰는 것만 씬별로 + 잘라 로컬 캐시에 저장한다. 이후 조회는 씬 하나당 수 MB 를 읽는 일이 된다. + + devkit 을 대체하려는 게 아니다 — 어노테이션/맵 API 가 필요하면 devkit 을 쓴다. + 이건 재구성·시뮬레이션 루프가 매번 두드리는 '뜨거운 경로' 만 덜어내는 것이다. +""" + +from __future__ import annotations + +import logging +import gzip +import json +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable, Iterator, Sequence + +from nusim import CAMERAS, SINGAPORE_MAPS +from nusim.io import iter_json_array +from nusim.paths import DatasetPaths, cache_dir, dataset + +INDEX_VERSION = 4 # 2: LIDAR_TOP 키프레임, 3: 3D 박스 어노테이션, 4: LIDAR_TOP sweep(20 Hz) +_INDEX_DIRNAME = "scene_index" +LIDAR = "LIDAR_TOP" + + +# --------------------------------------------------------------------------- 자료구조 +_log = logging.getLogger(__name__).info # 진행 메시지 기본 출력 → nusim.log (콘솔 + 로그 파일) + + +@dataclass(frozen=True) +class CameraCalib: + """차체(ego) 좌표계 기준 카메라 외부/내부 파라미터. + + translation/rotation 은 nuScenes 의 calibrated_sensor 그대로다: + 카메라 → ego 변환이며 rotation 은 wxyz 쿼터니언. + """ + + translation: tuple[float, float, float] + rotation: tuple[float, float, float, float] # w, x, y, z + intrinsic: tuple[tuple[float, float, float], ...] # 3x3 + width: int + height: int + + +@dataclass(frozen=True) +class Frame: + """카메라 한 대의 한 장.""" + + timestamp: int # 마이크로초 + filename: str # 데이터셋 루트 기준 상대경로 + ego_translation: tuple[float, float, float] + ego_rotation: tuple[float, float, float, float] # w, x, y, z, 전역(맵) → ego + is_keyframe: bool + + def path(self, paths: DatasetPaths) -> Path: + return paths.root / self.filename + + +@dataclass(frozen=True) +class Box: + """키프레임 하나에서 객체 하나의 3D 박스. 전역(맵) 좌표. + + nuScenes sample_annotation 그대로다: size 는 (w, l, h) — 박스 로컬 x 가 길이(l), + y 가 폭(w). 꼭짓점 계산은 nusim.data.rig.box_corners 를 쓴다. + """ + + timestamp: int # 마이크로초 (키프레임 sample 의 시각) + instance: int # SceneIndex.instances 의 번호 + translation: tuple[float, float, float] + size: tuple[float, float, float] # w, l, h + rotation: tuple[float, float, float, float] # w, x, y, z + attribute: str # 'vehicle.moving' 등. 없으면 '' + + +@dataclass +class SceneIndex: + """한 씬의 전체 멀티뷰 인덱스.""" + + token: str + name: str # 'scene-0001' + location: str # 'singapore-onenorth' + description: str + log_token: str + date_captured: str + vehicle: str + nbr_samples: int + calib: dict[str, CameraCalib] = field(default_factory=dict) + frames: dict[str, list[Frame]] = field(default_factory=dict) + # LIDAR_TOP 전부 (sweep 20 Hz, 씬당 약 400장). 키프레임(2 Hz)은 is_keyframe 으로 구분한다. + # 3DGS 초기화와 깊이 감독(카메라-라이다 융합)에 쓴다. + # lidar_calib 의 intrinsic 은 비어 있다 (nuScenes 가 라이다에 주지 않음). + lidar_calib: CameraCalib | None = None + lidar: list[Frame] = field(default_factory=list) + + def lidar_keyframes(self) -> list[Frame]: + return [f for f in self.lidar if f.is_keyframe] + # 3D 박스 (키프레임에만 있다). instances[i] = (instance_token, category_name) + instances: list[tuple[str, str]] = field(default_factory=list) + boxes: list[Box] = field(default_factory=list) + + @property + def n_images(self) -> int: + return sum(len(v) for v in self.frames.values()) + + def window(self, t0: float, t1: float) -> "SceneIndex": + """씬 시작부터 [t0, t1] 초 구간만 남긴 사본 (카메라·라이다 프레임). 박스는 그대로 — + 빠른 씬(수백 m)을 구간별로 나눠 재구성하거나 진단할 때 쓴다.""" + from dataclasses import replace + + start = min(f.timestamp for fr in self.frames.values() for f in fr[:1]) + lo, hi = start + int(t0 * 1e6), start + int(t1 * 1e6) + keep = lambda fr: [f for f in fr if lo <= f.timestamp <= hi] + return replace(self, frames={c: keep(fr) for c, fr in self.frames.items()}, lidar=keep(self.lidar)) + + def duration_s(self) -> float: + ts = [f.timestamp for v in self.frames.values() for f in v] + return (max(ts) - min(ts)) / 1e6 if ts else 0.0 + + def keyframes(self, camera: str) -> list[Frame]: + return [f for f in self.frames.get(camera, ()) if f.is_keyframe] + + # ---- 직렬화 (캐시는 사람이 읽을 수 있는 gzip JSON) ---- + def to_json(self) -> dict: + return { + "index_version": INDEX_VERSION, + "scene": { + "token": self.token, "name": self.name, "location": self.location, + "description": self.description, "log_token": self.log_token, + "date_captured": self.date_captured, "vehicle": self.vehicle, + "nbr_samples": self.nbr_samples, + }, + "calib": { + cam: { + "translation": list(c.translation), "rotation": list(c.rotation), + "intrinsic": [list(r) for r in c.intrinsic], + "width": c.width, "height": c.height, + } + for cam, c in self.calib.items() + }, + # 프레임은 리스트-of-리스트로 저장한다. 키 이름을 60만 번 반복하는 것보다 + # 파일이 3배 작고 파싱도 그만큼 빠르다. + "frame_schema": ["timestamp", "filename", "ex", "ey", "ez", "qw", "qx", "qy", "qz", "is_keyframe"], + "frames": { + cam: [ + [f.timestamp, f.filename, *f.ego_translation, *f.ego_rotation, int(f.is_keyframe)] + for f in fs + ] + for cam, fs in self.frames.items() + }, + "lidar_calib": None if self.lidar_calib is None else { + "translation": list(self.lidar_calib.translation), + "rotation": list(self.lidar_calib.rotation), + }, + "lidar": [ + [f.timestamp, f.filename, *f.ego_translation, *f.ego_rotation, int(f.is_keyframe)] + for f in self.lidar + ], + "instances": [list(i) for i in self.instances], + "box_schema": ["timestamp", "instance", "tx", "ty", "tz", "w", "l", "h", + "qw", "qx", "qy", "qz", "attribute"], + "boxes": [ + [b.timestamp, b.instance, *b.translation, *b.size, *b.rotation, b.attribute] + for b in self.boxes + ], + } + + @classmethod + def from_json(cls, d: dict) -> "SceneIndex": + if d.get("index_version") != INDEX_VERSION: + raise ValueError( + f"인덱스 버전 불일치 ({d.get('index_version')} != {INDEX_VERSION}). " + "'nusim index build --force' 로 다시 만드세요." + ) + s = d["scene"] + obj = cls(**s) + obj.calib = { + cam: CameraCalib( + translation=tuple(c["translation"]), rotation=tuple(c["rotation"]), + intrinsic=tuple(tuple(r) for r in c["intrinsic"]), + width=c["width"], height=c["height"], + ) + for cam, c in d["calib"].items() + } + obj.frames = { + cam: [ + Frame(timestamp=r[0], filename=r[1], + ego_translation=(r[2], r[3], r[4]), + ego_rotation=(r[5], r[6], r[7], r[8]), + is_keyframe=bool(r[9])) + for r in rows + ] + for cam, rows in d["frames"].items() + } + if lc := d.get("lidar_calib"): + obj.lidar_calib = CameraCalib( + translation=tuple(lc["translation"]), rotation=tuple(lc["rotation"]), + intrinsic=(), width=0, height=0, + ) + obj.lidar = [ + Frame(timestamp=r[0], filename=r[1], + ego_translation=(r[2], r[3], r[4]), + ego_rotation=(r[5], r[6], r[7], r[8]), + is_keyframe=bool(r[9])) + for r in d.get("lidar", ()) + ] + obj.instances = [tuple(i) for i in d.get("instances", ())] + obj.boxes = [ + Box(timestamp=r[0], instance=r[1], translation=(r[2], r[3], r[4]), + size=(r[5], r[6], r[7]), rotation=(r[8], r[9], r[10], r[11]), attribute=r[12]) + for r in d.get("boxes", ()) + ] + return obj + + +# --------------------------------------------------------------------------- 빌드 +def _index_dir() -> Path: + d = cache_dir() / _INDEX_DIRNAME + d.mkdir(parents=True, exist_ok=True) + return d + + +def build_index( + paths: DatasetPaths | None = None, + locations: Sequence[str] = SINGAPORE_MAPS, + cameras: Sequence[str] = CAMERAS, + include_sweeps: bool = True, + force: bool = False, + log=_log, +) -> dict: + """테이블을 훑어 씬별 인덱스를 캐시에 쓴다. 카탈로그(dict)를 돌려준다. + + include_sweeps=False 면 키프레임(2 Hz)만 담는다. 재구성 품질은 떨어지지만 + 인덱스가 12배 작아져 빠른 실험에 쓸 만하다. + """ + paths = paths or dataset() + out = _index_dir() + catalog_path = out / "catalog.json" + + if catalog_path.exists() and not force: + log(f"카탈로그가 이미 있습니다: {catalog_path} (--force 로 재생성)") + return json.loads(catalog_path.read_text()) + + t0 = time.time() + + # --- 1. 작은 테이블들 (합쳐서 8 MB 남짓) -------------------------------- + log("1/6 log / scene / sensor / calibrated_sensor 읽는 중") + logs = {r["token"]: r for r in iter_json_array(paths.meta / "log.json")} + sensors = {r["token"]: r for r in iter_json_array(paths.meta / "sensor.json")} + calibs = {r["token"]: r for r in iter_json_array(paths.meta / "calibrated_sensor.json")} + + # calibrated_sensor → 카메라 채널 이름. sample_data 필터링에 이걸 쓴다. + cam_set = set(cameras) + calib_channel = { + tok: sensors[c["sensor_token"]]["channel"] + for tok, c in calibs.items() + if sensors[c["sensor_token"]]["channel"] in cam_set + } + lidar_calibs = { + tok for tok, c in calibs.items() if sensors[c["sensor_token"]]["channel"] == LIDAR + } + + wanted_loc = set(locations) + scenes: dict[str, dict] = {} + for r in iter_json_array(paths.meta / "scene.json"): + lg = logs[r["log_token"]] + if lg["location"] in wanted_loc: + scenes[r["token"]] = {**r, "_log": lg} + log(f" 대상 씬 {len(scenes)}개 ({', '.join(sorted(wanted_loc))})") + if not scenes: + raise RuntimeError(f"location {sorted(wanted_loc)} 에 해당하는 씬이 없습니다.") + + # --- 2. sample → scene (7 MB) ------------------------------------------- + log("2/6 sample.json 읽는 중") + sample_scene: dict[str, str] = {} + sample_ts: dict[str, int] = {} + for r in iter_json_array(paths.meta / "sample.json"): + if r["scene_token"] in scenes: + sample_scene[r["token"]] = r["scene_token"] + sample_ts[r["token"]] = r["timestamp"] + log(f" 키프레임 샘플 {len(sample_scene):,}개") + + # --- 3. sample_data 스트리밍 (1.35 GB) ---------------------------------- + # 가장 비싼 단계. 한 번만 훑으면서 필요한 행만 건진다. + log("3/6 sample_data.json 스트리밍 (1.35 GB — 30초 내외)") + per_scene: dict[str, dict[str, list]] = {t: {c: [] for c in cameras} for t in scenes} + need_pose: set[str] = set() + scene_calib: dict[str, dict[str, str]] = {t: {} for t in scenes} + per_scene_lidar: dict[str, list] = {t: [] for t in scenes} + scene_lidar_calib: dict[str, str] = {} + seen = kept = 0 + + for r in iter_json_array(paths.meta / "sample_data.json"): + seen += 1 + cs = r["calibrated_sensor_token"] + chan = calib_channel.get(cs) + if chan is None: + # 라이다는 sweep 까지 전부 건진다 (RADAR 는 버린다). + if cs in lidar_calibs: + st = sample_scene.get(r["sample_token"]) + if st is not None: + per_scene_lidar[st].append( + (r["timestamp"], r["filename"], r["ego_pose_token"], bool(r["is_key_frame"]))) + need_pose.add(r["ego_pose_token"]) + scene_lidar_calib.setdefault(st, cs) + continue + st = sample_scene.get(r["sample_token"]) + if st is None: # 싱가포르 씬이 아님 + continue + if not include_sweeps and not r["is_key_frame"]: + continue + per_scene[st][chan].append( + (r["timestamp"], r["filename"], r["ego_pose_token"], r["is_key_frame"]) + ) + need_pose.add(r["ego_pose_token"]) + scene_calib[st].setdefault(chan, r["calibrated_sensor_token"]) + kept += 1 + log(f" {seen:,}행 중 {kept:,}행 채택, ego_pose {len(need_pose):,}개 필요") + + # --- 4. ego_pose 스트리밍 (645 MB) -------------------------------------- + log("4/6 ego_pose.json 스트리밍 (645 MB)") + poses: dict[str, tuple] = {} + for r in iter_json_array(paths.meta / "ego_pose.json"): + if r["token"] in need_pose: + t = r["translation"] + q = r["rotation"] + poses[r["token"]] = (t[0], t[1], t[2], q[0], q[1], q[2], q[3]) + missing = len(need_pose) - len(poses) + if missing: + raise RuntimeError(f"ego_pose {missing}개를 찾지 못했습니다 — 데이터셋이 불완전합니다.") + + # --- 5. 3D 박스 (sample_annotation 583 MB) ------------------------------ + log("5/6 sample_annotation.json 스트리밍 (583 MB)") + categories = {r["token"]: r["name"] for r in iter_json_array(paths.meta / "category.json")} + attributes = {r["token"]: r["name"] for r in iter_json_array(paths.meta / "attribute.json")} + inst_cat = {r["token"]: categories[r["category_token"]] + for r in iter_json_array(paths.meta / "instance.json")} + per_scene_inst: dict[str, dict[str, int]] = {t: {} for t in scenes} + per_scene_boxes: dict[str, list] = {t: [] for t in scenes} + n_box = 0 + for r in iter_json_array(paths.meta / "sample_annotation.json"): + st = sample_scene.get(r["sample_token"]) + if st is None: + continue + ids = per_scene_inst[st] + inst = ids.setdefault(r["instance_token"], len(ids)) + attr = attributes[r["attribute_tokens"][0]] if r["attribute_tokens"] else "" + per_scene_boxes[st].append(Box( + timestamp=sample_ts[r["sample_token"]], instance=inst, + translation=tuple(r["translation"]), size=tuple(r["size"]), + rotation=tuple(r["rotation"]), attribute=attr, + )) + n_box += 1 + log(f" 박스 {n_box:,}개") + + # --- 6. 씬별로 쓰기 ------------------------------------------------------ + log("6/6 씬별 인덱스 기록") + catalog = { + "index_version": INDEX_VERSION, + "built_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "dataset_root": str(paths.root), + "version": paths.version, + "locations": sorted(wanted_loc), + "cameras": list(cameras), + "include_sweeps": include_sweeps, + "scenes": [], + } + + for tok, sc in sorted(scenes.items(), key=lambda kv: kv[1]["name"]): + si = SceneIndex( + token=tok, name=sc["name"], location=sc["_log"]["location"], + description=sc["description"], log_token=sc["log_token"], + date_captured=sc["_log"]["date_captured"], vehicle=sc["_log"]["vehicle"], + nbr_samples=sc["nbr_samples"], + ) + for cam, cs_tok in scene_calib[tok].items(): + c = calibs[cs_tok] + # 이 채널의 첫 프레임에서 해상도를 가져온다 (씬 안에서는 불변). + si.calib[cam] = CameraCalib( + translation=tuple(c["translation"]), + rotation=tuple(c["rotation"]), + intrinsic=tuple(tuple(row) for row in c["camera_intrinsic"]), + width=1600, height=900, # nuScenes 카메라는 전부 1600x900 + ) + for cam in cameras: + rows = sorted(per_scene[tok][cam]) + si.frames[cam] = [ + Frame(timestamp=ts, filename=fn, + ego_translation=poses[ep][:3], ego_rotation=poses[ep][3:], + is_keyframe=bool(kf)) + for ts, fn, ep, kf in rows + ] + if cs_tok := scene_lidar_calib.get(tok): + c = calibs[cs_tok] + si.lidar_calib = CameraCalib( + translation=tuple(c["translation"]), rotation=tuple(c["rotation"]), + intrinsic=(), width=0, height=0, + ) + si.lidar = [ + Frame(timestamp=ts, filename=fn, + ego_translation=poses[ep][:3], ego_rotation=poses[ep][3:], + is_keyframe=kf) + for ts, fn, ep, kf in sorted(per_scene_lidar[tok]) + ] + si.instances = [(it, inst_cat[it]) for it, _ in + sorted(per_scene_inst[tok].items(), key=lambda kv: kv[1])] + si.boxes = sorted(per_scene_boxes[tok], key=lambda b: (b.timestamp, b.instance)) + _write_scene(si) + catalog["scenes"].append({ + "name": si.name, "token": si.token, "location": si.location, + "n_images": si.n_images, "nbr_samples": si.nbr_samples, + "duration_s": round(si.duration_s(), 2), "description": si.description, + }) + + catalog_path.write_text(json.dumps(catalog, indent=2, ensure_ascii=False)) + total_img = sum(s["n_images"] for s in catalog["scenes"]) + log(f"완료: 씬 {len(catalog['scenes'])}개 / 이미지 {total_img:,}장 / {time.time()-t0:.1f}초") + log(f" → {out}") + return catalog + + +def _write_scene(si: SceneIndex) -> Path: + p = _index_dir() / f"{si.name}.json.gz" + with gzip.open(p, "wt", encoding="utf-8") as f: + json.dump(si.to_json(), f, ensure_ascii=False) + return p + + +# --------------------------------------------------------------------------- 조회 +def load_catalog() -> dict: + p = _index_dir() / "catalog.json" + if not p.exists(): + raise FileNotFoundError( + f"씬 인덱스가 없습니다 ({p}).\n먼저 만드세요: nusim index build" + ) + return json.loads(p.read_text()) + + +def load_scene(name: str) -> SceneIndex: + """'scene-0001' 같은 이름으로 씬 인덱스를 읽는다.""" + p = _index_dir() / f"{name}.json.gz" + if not p.exists(): + raise FileNotFoundError(f"{name} 인덱스가 없습니다 ({p}). 'nusim index build' 를 먼저 실행하세요.") + with gzip.open(p, "rt", encoding="utf-8") as f: + return SceneIndex.from_json(json.load(f)) + + +def iter_scenes(location: str | None = None) -> Iterator[SceneIndex]: + for entry in load_catalog()["scenes"]: + if location and entry["location"] != location: + continue + yield load_scene(entry["name"]) diff --git a/src/nusim/nusim/data/rig.py b/src/nusim/nusim/data/rig.py new file mode 100644 index 0000000000000000000000000000000000000000..29e69eaa9d19d7a7e17246b89c9605e9b984c234 --- /dev/null +++ b/src/nusim/nusim/data/rig.py @@ -0,0 +1,204 @@ +"""카메라 리그 수학 — nuScenes 포즈를 렌더러가 쓰는 행렬로 바꾼다. + +좌표계 약속 (이 프로젝트 전체에서 고정): + + global / map nuScenes 맵 좌표. x=동, y=북, z=위. 미터. + ego 차체. x=전방, y=좌, z=상. + camera OpenCV 관례. x=우, y=하, z=전방. ← nuScenes intrinsic 이 이걸 가정한다 + clip Vulkan 관례. NDC x,y ∈ [-1,1], y 아래가 +, z ∈ [0,1]. + +nuScenes 원본의 의미: + ego_pose .translation/.rotation = ego → global + calibrated_sensor .translation/.rotation = camera → ego + sample_annotation .translation/.rotation = box → global, 박스 중심 기준 + .size = (w, l, h). 박스 로컬 x 가 길이(l), y 가 폭(w), z 가 높이 + 쿼터니언은 전부 (w, x, y, z) 순서다. + +따라서 camera → global = ego_pose ∘ calib 이고, +렌더러가 필요로 하는 view 행렬은 그 역이다. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from nusim.data.index import CameraCalib, Frame + + +def quat_to_matrix(q: tuple[float, float, float, float]) -> np.ndarray: + """(w,x,y,z) 쿼터니언 → 3x3 회전행렬. + + pyquaternion 을 쓰지 않는 이유: 이 함수는 프레임마다 6번씩 수십만 번 불린다. + 의존성 없는 순수 numpy 쪽이 10배 이상 빠르고, 식도 짧다. + """ + w, x, y, z = q + n = w * w + x * x + y * y + z * z + if n < 1e-12: + return np.eye(3) + s = 2.0 / n + wx, wy, wz = s * w * x, s * w * y, s * w * z + xx, xy, xz = s * x * x, s * x * y, s * x * z + yy, yz, zz = s * y * y, s * y * z, s * z * z + return np.array([ + [1.0 - (yy + zz), xy - wz, xz + wy], + [xy + wz, 1.0 - (xx + zz), yz - wx], + [xz - wy, yz + wx, 1.0 - (xx + yy)], + ]) + + +def matrix_to_quat(r: np.ndarray) -> tuple[float, float, float, float]: + """3x3 회전행렬 → (w,x,y,z) 쿼터니언 (quat_to_matrix 의 역, w ≥ 0). Shepperd 방법.""" + m = np.asarray(r, dtype=np.float64) + tr = m[0, 0] + m[1, 1] + m[2, 2] + if tr > 0: + s = 2.0 * np.sqrt(tr + 1.0) + q = (0.25 * s, (m[2, 1] - m[1, 2]) / s, (m[0, 2] - m[2, 0]) / s, (m[1, 0] - m[0, 1]) / s) + elif m[0, 0] > m[1, 1] and m[0, 0] > m[2, 2]: + s = 2.0 * np.sqrt(1.0 + m[0, 0] - m[1, 1] - m[2, 2]) + q = ((m[2, 1] - m[1, 2]) / s, 0.25 * s, (m[0, 1] + m[1, 0]) / s, (m[0, 2] + m[2, 0]) / s) + elif m[1, 1] > m[2, 2]: + s = 2.0 * np.sqrt(1.0 + m[1, 1] - m[0, 0] - m[2, 2]) + q = ((m[0, 2] - m[2, 0]) / s, (m[0, 1] + m[1, 0]) / s, 0.25 * s, (m[1, 2] + m[2, 1]) / s) + else: + s = 2.0 * np.sqrt(1.0 + m[2, 2] - m[0, 0] - m[1, 1]) + q = ((m[1, 0] - m[0, 1]) / s, (m[0, 2] + m[2, 0]) / s, (m[1, 2] + m[2, 1]) / s, 0.25 * s) + q = np.asarray(q) + q = q / np.linalg.norm(q) + if q[0] < 0: + q = -q + return tuple(float(x) for x in q) + + +def rt_to_matrix(r: np.ndarray, t) -> np.ndarray: + """3x3 회전 + 3벡터 → 4x4 동차변환.""" + m = np.eye(4) + m[:3, :3] = r + m[:3, 3] = t + return m + + +def box_corners(translation, size, rotation, pad: float = 0.0) -> np.ndarray: + """3D 박스 → 전역 좌표 꼭짓점 (8,3). devkit Box.corners() 와 같은 순서. + + pad 는 각 방향으로 더하는 여유(m). 어노테이션 박스는 객체에 딱 붙어 있어서 + 그림자·백미러·보행자 팔다리가 삐져나온다. + """ + w, l, h = (np.asarray(size, dtype=np.float64) + 2 * pad) + x = l / 2 * np.array([1, 1, 1, 1, -1, -1, -1, -1]) + y = w / 2 * np.array([1, -1, -1, 1, 1, -1, -1, 1]) + z = h / 2 * np.array([1, 1, -1, -1, 1, 1, -1, -1]) + return np.stack([x, y, z], axis=1) @ quat_to_matrix(rotation).T + np.asarray(translation) + + +def points_in_box(pts: np.ndarray, translation, size, rotation, pad: float = 0.0) -> np.ndarray: + """(N,3) 전역 좌표 점 중 박스 안에 있는 것의 bool 마스크.""" + local = (pts - np.asarray(translation)) @ quat_to_matrix(rotation) # R^T 를 곱하는 것과 같다 + w, l, h = (np.asarray(size, dtype=np.float64) + 2 * pad) / 2 + return (np.abs(local[:, 0]) <= l) & (np.abs(local[:, 1]) <= w) & (np.abs(local[:, 2]) <= h) + + +@dataclass(frozen=True) +class CameraPose: + """한 시점의 카메라 하나. 렌더러에 넘길 것이 전부 들어있다.""" + + channel: str + timestamp: int + c2w: np.ndarray # 4x4, camera → global + K: np.ndarray # 3x3 intrinsic + width: int + height: int + filename: str = "" + + @property + def w2c(self) -> np.ndarray: + """view 행렬. 회전은 전치, 평행이동은 그에 맞춰 다시 계산한다 + (일반 역행렬을 쓰면 수치오차가 누적된다).""" + r = self.c2w[:3, :3] + m = np.eye(4) + m[:3, :3] = r.T + m[:3, 3] = -r.T @ self.c2w[:3, 3] + return m + + @property + def position(self) -> np.ndarray: + return self.c2w[:3, 3].copy() + + def projection(self, znear: float = 0.1, zfar: float = 200.0) -> np.ndarray: + """intrinsic → Vulkan clip 공간 4x4 투영행렬. + + OpenCV 카메라(y 아래, z 전방)와 Vulkan NDC(y 아래, z ∈ [0,1]) 는 축 방향이 같다. + 그래서 OpenGL 식과 달리 y 뒤집기도, z 부호 반전도 없다. 정확히 + ndc_x = 2u/w - 1, ndc_y = 2v/h - 1 (u,v = K 로 투영한 픽셀 좌표) + 가 성립하고, tests/test_rig.py 가 이걸 픽셀 단위로 검증한다. + """ + fx, fy = self.K[0, 0], self.K[1, 1] + cx, cy = self.K[0, 2], self.K[1, 2] + w, h = self.width, self.height + + p = np.zeros((4, 4)) + p[0, 0] = 2.0 * fx / w + p[1, 1] = 2.0 * fy / h + # 주점 오프셋. u = fx·x/z + cx 이므로 2u/w-1 = (2fx/w)·x/z + (2cx/w - 1). + p[0, 2] = 2.0 * cx / w - 1.0 + p[1, 2] = 2.0 * cy / h - 1.0 + # 깊이 [znear, zfar] → [0, 1] + p[2, 2] = zfar / (zfar - znear) + p[2, 3] = -zfar * znear / (zfar - znear) + p[3, 2] = 1.0 + return p + + def fov_deg(self) -> tuple[float, float]: + fx, fy = self.K[0, 0], self.K[1, 1] + return ( + float(np.degrees(2 * np.arctan(self.width / (2 * fx)))), + float(np.degrees(2 * np.arctan(self.height / (2 * fy)))), + ) + + +def camera_pose(frame: Frame, calib: CameraCalib, channel: str) -> CameraPose: + """한 프레임 + 그 채널의 캘리브 → CameraPose.""" + ego2global = rt_to_matrix(quat_to_matrix(frame.ego_rotation), frame.ego_translation) + cam2ego = rt_to_matrix(quat_to_matrix(calib.rotation), calib.translation) + return CameraPose( + channel=channel, + timestamp=frame.timestamp, + c2w=ego2global @ cam2ego, + K=np.asarray(calib.intrinsic, dtype=np.float64), + width=calib.width, + height=calib.height, + filename=frame.filename, + ) + + +def scene_poses(scene, cameras=None, keyframes_only: bool = False) -> list[CameraPose]: + """씬 전체의 카메라 포즈를 시간순으로. 3DGS 학습기의 입력이 되는 형태다.""" + from nusim import CAMERAS + + out: list[CameraPose] = [] + for cam in cameras or CAMERAS: + calib = scene.calib.get(cam) + if calib is None: + continue + frames = scene.keyframes(cam) if keyframes_only else scene.frames.get(cam, ()) + out.extend(camera_pose(f, calib, cam) for f in frames) + out.sort(key=lambda p: (p.timestamp, p.channel)) + return out + + +def ego_trajectory(scene, channel: str = "CAM_FRONT") -> np.ndarray: + """(N, 8) 배열: t[s], x, y, z, qw, qx, qy, qz. + + closed-loop 시작 상태와 원본 궤적 비교(오픈루프 기준선)에 쓴다. + """ + frames = scene.frames.get(channel, []) + if not frames: + raise ValueError(f"{scene.name} 에 {channel} 프레임이 없습니다") + t0 = frames[0].timestamp + return np.array( + [ + [(f.timestamp - t0) / 1e6, *f.ego_translation, *f.ego_rotation] + for f in frames + ] + ) diff --git a/src/nusim/nusim/io/__init__.py b/src/nusim/nusim/io/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..504db0dea709149b4bd2e3ee8b5e5def8f4a7303 --- /dev/null +++ b/src/nusim/nusim/io/__init__.py @@ -0,0 +1,5 @@ +"""직렬화 계층 — 대용량 JSON 스트리밍, 3DGS .ply 입출력.""" + +from nusim.io.jsonstream import iter_json_array, load_table + +__all__ = ["iter_json_array", "load_table"] diff --git a/src/nusim/nusim/io/jsonstream.py b/src/nusim/nusim/io/jsonstream.py new file mode 100644 index 0000000000000000000000000000000000000000..eaac2eab7097fb81e53cc0dceef741549f5392a0 --- /dev/null +++ b/src/nusim/nusim/io/jsonstream.py @@ -0,0 +1,103 @@ +"""nuScenes 테이블을 위한 스트리밍 JSON 리더. + +왜 필요한가: + v1.0-trainval 의 sample_data.json 은 1.3 GB, ego_pose.json 은 645 MB 다. + json.load() 로 읽으면 파이썬 객체 그래프가 원본의 4~5배로 부풀어 + sample_data 하나에 6 GB 안팎을 먹는다. 게다가 이 파일들은 NFS 위에 있어 + 전체를 읽는 것 자체가 비싸다. + + 여기서는 최상위 배열의 원소를 하나씩 흘려보낸다. 호출자가 필요한 것만 + 골라 담으면 피크 메모리가 '버퍼 크기 + 고른 것' 으로 떨어진다. + 실측: sample_data.json 전체 순회 시 피크 ~60 MB. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Callable, Iterator + +# 원소 사이의 공백과 쉼표. raw_decode 는 idx 가 값의 첫 글자를 가리켜야 한다. +_GAP = re.compile(r"[\s,]*") + +_CHUNK = 1 << 22 # 4 MiB +# 버퍼 앞쪽의 소비된 부분을 잘라내는 임계치. 매 원소마다 자르면 슬라이스 복사가 +# O(n²) 가 되고, 아예 안 자르면 버퍼가 파일 크기만큼 자란다. +_COMPACT_AT = 1 << 21 # 2 MiB + + +def iter_json_array(path: str | Path, chunk_size: int = _CHUNK) -> Iterator[Any]: + """최상위가 JSON 배열인 파일의 원소를 하나씩 내놓는다. + + nuScenes 의 모든 테이블이 이 모양이다. 다른 모양이면 ValueError 를 낸다. + """ + path = Path(path) + dec = json.JSONDecoder() + + with path.open("r", encoding="utf-8") as f: + buf = f.read(chunk_size) + if not buf: + return + + start = buf.find("[") + if start < 0: + raise ValueError(f"{path}: 최상위 JSON 배열이 아닙니다") + pos = start + 1 + + while True: + pos = _GAP.match(buf, pos).end() + + # 버퍼가 바닥났으면 더 읽는다. + if pos >= len(buf): + more = f.read(chunk_size) + if not more: + return # 닫는 ']' 없이 끝남 — 잘린 파일이지만 얻은 건 이미 냈다 + buf = buf[pos:] + more + pos = 0 + continue + + if buf[pos] == "]": + return + + try: + obj, pos = dec.raw_decode(buf, pos) + except ValueError: + # 원소가 버퍼 경계에 걸쳤다. 더 읽어서 다시 시도한다. + more = f.read(chunk_size) + if not more: + raise ValueError(f"{path}: 파일 끝에서 JSON 이 잘렸습니다") from None + buf += more + continue + + yield obj + + if pos > _COMPACT_AT: + buf = buf[pos:] + pos = 0 + + +def load_table( + path: str | Path, + keep: Callable[[dict], bool] | None = None, + fields: tuple[str, ...] | None = None, +) -> list[dict]: + """테이블을 읽되 원하는 행과 열만 남긴다. + + keep: 행 필터. None 이면 전부. + fields: 남길 키. None 이면 전부. 큰 테이블에서는 이걸 주는 게 메모리를 크게 줄인다. + """ + out: list[dict] = [] + for row in iter_json_array(path): + if keep is not None and not keep(row): + continue + out.append({k: row[k] for k in fields} if fields else row) + return out + + +def index_table(path: str | Path, key: str = "token", fields: tuple[str, ...] | None = None) -> dict[str, dict]: + """테이블을 key(기본 'token') 로 찾을 수 있는 dict 로 만든다.""" + out: dict[str, dict] = {} + for row in iter_json_array(path): + out[row[key]] = {k: row[k] for k in fields} if fields else row + return out diff --git a/src/nusim/nusim/io/ktx2.py b/src/nusim/nusim/io/ktx2.py new file mode 100644 index 0000000000000000000000000000000000000000..9e2490448c42faca4e52150e3dffd8a895289ee2 --- /dev/null +++ b/src/nusim/nusim/io/ktx2.py @@ -0,0 +1,139 @@ +"""KTX2 (Khronos Texture 2.0) 쓰기/읽기 — Vulkan 렌더러에 큐브맵·LUT 를 넘기는 포맷. + +https://registry.khronos.org/KTX/specs/2.0/ktxspec.v2.html +필요한 것만: 2D / 큐브맵, mip 체인, 초압축 없음(supercompression 0). +블록 압축(BC6H) 데이터는 이미 압축된 바이트를 그대로 담는다. + +파일 안 데이터 순서는 스펙대로 **작은 mip 부터** 큰 mip 순이고, 각 mip 안에서는 면 0~5 순서다. +레벨 인덱스는 mip 0(가장 큰 것)부터 적는다. +""" + +from __future__ import annotations + +import math +import struct +from dataclasses import dataclass +from pathlib import Path + +IDENTIFIER = b"\xabKTX 20\xbb\r\n\x1a\n" + +# 쓰는 VkFormat +VK_FORMAT_R16G16_SFLOAT = 83 +VK_FORMAT_R16G16B16A16_SFLOAT = 97 +VK_FORMAT_BC6H_UFLOAT_BLOCK = 143 + +_BLOCK = {VK_FORMAT_BC6H_UFLOAT_BLOCK: (4, 4, 16)} # 블록 가로, 세로, 바이트 +_TEXEL = {VK_FORMAT_R16G16_SFLOAT: 4, VK_FORMAT_R16G16B16A16_SFLOAT: 8} + + +def level_size(vk_format: int, w: int, h: int) -> int: + if vk_format in _BLOCK: + bw, bh, nb = _BLOCK[vk_format] + return ((w + bw - 1) // bw) * ((h + bh - 1) // bh) * nb + return w * h * _TEXEL[vk_format] + + +def _dfd(vk_format: int) -> bytes: + """기본 데이터 형식 서술자(DFD). 뷰어는 vkFormat 만 보지만 외부 도구 호환을 위해 넣는다.""" + # KHR_DF_MODEL: RGBSDA=1, BC6H=131 / 전송 함수 LINEAR=1 / 원색 BT709=1 + if vk_format == VK_FORMAT_BC6H_UFLOAT_BLOCK: + model, dims, plane0 = 133, (3, 3, 0, 0), 16 # KHR_DF_MODEL_BC6H (131 은 BC4) + # 샘플 1개: 128 비트 블록 전체, 채널 COLOR(0), FLOAT 플래그(0x80) + samples = [(0, 127, 0x80, 0, 0x3F800000)] # sampleUpper = 1.0f (ktx validate 기준) + elif vk_format in (VK_FORMAT_R16G16_SFLOAT, VK_FORMAT_R16G16B16A16_SFLOAT): + n = 2 if vk_format == VK_FORMAT_R16G16_SFLOAT else 4 + model, dims, plane0 = 1, (0, 0, 0, 0), 2 * n + chan = [0, 1, 2, 15][:n] # R, G, B, A + # FLOAT(0x80) | SIGNED(0x40). 범위 -1..1 의 float 비트 + samples = [(16 * i, 15, 0xC0, chan[i], (0xBF800000, 0x3F800000)) for i in range(n)] + else: + raise ValueError(f"DFD 미지원 vkFormat {vk_format}") + body = b"" + for s in samples: + off, length, flags, ch, rng = s if len(s) == 5 else (*s, 0) + lower, upper = rng if isinstance(rng, tuple) else (0, rng) + body += struct.pack(" Path: + path = Path(path) + nlev = len(tex.levels) + for m, lv in enumerate(tex.levels): + w, h = max(1, tex.width >> m), max(1, tex.height >> m) + for f in lv: + if len(f) != level_size(tex.vk_format, w, h): + raise ValueError(f"mip {m} 크기 {len(f)} != 기대 {level_size(tex.vk_format, w, h)}") + dfd = _dfd(tex.vk_format) + kvd = b"" + for k, v in (tex.kv or {}).items(): + kv = k.encode() + b"\0" + v.encode() + b"\0" + kvd += struct.pack(" Ktx2: + b = Path(path).read_bytes() + if b[:12] != IDENTIFIER: + raise ValueError(f"{path}: KTX2 가 아닙니다") + vk_format, _ts, w, h, _d, _layers, faces, nlev, sc = struct.unpack_from("<9I", b, 12) + if sc: + raise ValueError(f"{path}: 초압축(supercompression {sc}) 미지원") + nlev = max(nlev, 1) + levels = [] + for m in range(nlev): + off, n, _ = struct.unpack_from("<3Q", b, 12 + 36 + 16 + 16 + 24 * m) + per = n // faces + levels.append([b[off + f * per: off + (f + 1) * per] for f in range(faces)]) + return Ktx2(vk_format, w, h, faces, levels) diff --git a/src/nusim/nusim/io/ply.py b/src/nusim/nusim/io/ply.py new file mode 100644 index 0000000000000000000000000000000000000000..ae354629937e29ce50f2ab9a2d75fa0a14a7a251 --- /dev/null +++ b/src/nusim/nusim/io/ply.py @@ -0,0 +1,170 @@ +"""표준 3DGS .ply 입출력 — 학습기와 Vulkan 렌더러 사이의 유일한 접점. + +포맷은 원본 3DGS (Kerbl et al. 2023) 구현이 쓰는 그대로다. 그래서 SIBR 뷰어, +SuperSplat 같은 외부 도구로도 열린다. binary_little_endian, vertex 하나에 float32: + + x y z 위치 (씬 원점 기준, 아래 참고) + nx ny nz 항상 0. 원본 포맷에 자리만 있다 + f_dc_0..2 SH 0차 계수 (RGB) + f_rest_0..3(K-1)-1 SH 고차 계수. **채널 우선** — R 의 K-1 개, G, B 순서 + opacity logit (sigmoid 전) + scale_0..2 log (exp 전) + rot_0..3 쿼터니언 (w,x,y,z), 정규화 전 + +씬 원점: nuScenes 전역 좌표는 수천 m 단위라 float32 로 담으면 cm 이하가 뭉개진다. +그래서 위치는 씬 원점을 뺀 값으로 저장하고, 원점은 헤더의 `comment nusim_origin` +에 float64 로 남긴다. 표준 리더는 comment 를 무시하므로 호환성이 깨지지 않는다. +전역 좌표 = 저장된 위치 + origin. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np + +_ORIGIN_TAG = "nusim_origin" + + +@dataclass +class Gaussians: + """가우시안 집합. 전부 활성화 전(raw) 파라미터다 — 학습기 내부 표현과 같다.""" + + means: np.ndarray # (N,3) 씬 원점 기준 + scales: np.ndarray # (N,3) log + quats: np.ndarray # (N,4) wxyz + opacities: np.ndarray # (N,) logit + sh0: np.ndarray # (N,1,3) + shN: np.ndarray # (N,K-1,3), K = (deg+1)^2. deg 0 이면 (N,0,3) + origin: np.ndarray = field(default_factory=lambda: np.zeros(3)) # float64, 전역 좌표 + + def __len__(self) -> int: + return len(self.means) + + @property + def sh_degree(self) -> int: + k = 1 + self.shN.shape[1] + deg = int(round(np.sqrt(k))) - 1 + if (deg + 1) ** 2 != k: + raise ValueError(f"SH 계수 개수 {k} 가 (deg+1)^2 꼴이 아닙니다") + return deg + + +def _attr_names(n_rest: int) -> list[str]: + return ( + ["x", "y", "z", "nx", "ny", "nz"] + + [f"f_dc_{i}" for i in range(3)] + + [f"f_rest_{i}" for i in range(n_rest)] + + ["opacity"] + + [f"scale_{i}" for i in range(3)] + + [f"rot_{i}" for i in range(4)] + ) + + +def write_ply(path: str | Path, g: Gaussians) -> Path: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + n = len(g) + # (N,K-1,3) → (N,3,K-1) → 평탄화. 원본 3DGS 의 f_rest 순서가 이것이다. + rest = np.ascontiguousarray(g.shN.transpose(0, 2, 1)).reshape(n, -1) + cols = [ + g.means, np.zeros((n, 3)), g.sh0.reshape(n, 3), rest, + g.opacities.reshape(n, 1), g.scales, g.quats, + ] + data = np.concatenate([np.asarray(c, dtype=np.float32) for c in cols], axis=1) + names = _attr_names(rest.shape[1]) + assert data.shape[1] == len(names) + + o = np.asarray(g.origin, dtype=np.float64) + header = "\n".join( + ["ply", "format binary_little_endian 1.0", + f"comment {_ORIGIN_TAG} {o[0]!r} {o[1]!r} {o[2]!r}", + f"element vertex {n}"] + + [f"property float {a}" for a in names] + + ["end_header"] + ) + "\n" + + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "wb") as f: + f.write(header.encode("ascii")) + f.write(data.astype(" np.ndarray: + """헤더만 읽어 씬 원점을 돌려준다. 가우시안 200만 개짜리 ply 를 다 읽지 않으려고.""" + with open(path, "rb") as f: + for raw in f: + tok = raw.decode("ascii", "replace").split() + if tok[:2] == ["comment", _ORIGIN_TAG] and len(tok) == 5: + return np.array([float(t) for t in tok[2:5]]) + if tok[:1] == ["end_header"]: + break + return np.zeros(3) + + +def read_count(path: str | Path) -> int: + """헤더만 읽어 가우시안 수 (element vertex).""" + with open(path, "rb") as f: + for raw in f: + tok = raw.decode("ascii", "replace").split() + if tok[:2] == ["element", "vertex"]: + return int(tok[2]) + if tok[:1] == ["end_header"]: + break + raise ValueError(f"{path}: element vertex 가 없다") + + +def read_ply(path: str | Path) -> Gaussians: + path = Path(path) + with open(path, "rb") as f: + if f.readline().strip() != b"ply": + raise ValueError(f"{path}: ply 파일이 아닙니다") + names: list[str] = [] + n = None + origin = np.zeros(3) + while True: + line = f.readline() + if not line: + raise ValueError(f"{path}: end_header 가 없습니다") + tok = line.decode("ascii").split() + if not tok: + continue + if tok[0] == "format" and tok[1] != "binary_little_endian": + raise ValueError(f"{path}: {tok[1]} 는 지원하지 않습니다") + elif tok[0] == "comment" and len(tok) == 5 and tok[1] == _ORIGIN_TAG: + origin = np.array([float(t) for t in tok[2:5]]) + elif tok[0] == "element": + if tok[1] != "vertex": + raise ValueError(f"{path}: vertex 외 element({tok[1]}) 는 지원하지 않습니다") + n = int(tok[2]) + elif tok[0] == "property": + if tok[1] != "float": + raise ValueError(f"{path}: float 가 아닌 속성 {tok[1]} {tok[2]}") + names.append(tok[2]) + elif tok[0] == "end_header": + break + if n is None: + raise ValueError(f"{path}: vertex 개수가 없습니다") + data = np.frombuffer(f.read(n * len(names) * 4), dtype=" np.ndarray: + return data[:, [col[f"{prefix}{i}"] for i in range(k)]] + + n_rest = sum(1 for a in names if a.startswith("f_rest_")) + if n_rest % 3: + raise ValueError(f"{path}: f_rest 개수 {n_rest} 가 3의 배수가 아닙니다") + rest = pick("f_rest_", n_rest).reshape(n, 3, n_rest // 3).transpose(0, 2, 1) + return Gaussians( + means=data[:, [col["x"], col["y"], col["z"]]].copy(), + scales=pick("scale_", 3).copy(), + quats=pick("rot_", 4).copy(), + opacities=data[:, col["opacity"]].copy(), + sh0=pick("f_dc_", 3).reshape(n, 1, 3).copy(), + shN=np.ascontiguousarray(rest), + origin=origin, + ) diff --git a/src/nusim/nusim/jobs.py b/src/nusim/nusim/jobs.py new file mode 100644 index 0000000000000000000000000000000000000000..2369150a9ea0acd6cef181998943e29c178214ee --- /dev/null +++ b/src/nusim/nusim/jobs.py @@ -0,0 +1,116 @@ +"""작업 정의(JSON) 실행기 — `nusim run `. + +실험마다 셸 대기열 스크립트(cache/queue_*.sh)를 새로 쓰던 것을 대신한다. 작업 파일 형식: + + { + "name": "quality_2026-10-05", + "vars": {"iters": "15000", "base": "--strategy mcmc --depth-lambda 0.1 --objects agents"}, + "steps": [ + {"name": "pilot-${s}", "foreach": {"s": ["scene-0184", "scene-0402"]}, + "clean": ["outputs/gs_day/${s}"], + "run": "recon batch --scenes ${s} --no-sky --iters ${iters}"}, + {"name": "exp-fullres", "run": "recon train scene-0003 ${base} --iters ${iters} --downscale 1 --out outputs/gs_exp/fullres"} + ] + } + +- run: nusim 하위 명령 (문자열이면 공백으로 나눈다, 목록도 된다). ${변수} 는 vars·foreach 값으로 바꾼다. +- foreach: 변수마다 값 목록 — 단계가 값 수만큼 펼쳐진다 (여러 변수면 곱). 값이 객체면 ${e.id}, ${e.args} 처럼. +- clean: 실행 전에 지울 디렉터리 (다시 만들 결과). +- continue_on_error: 실패해도 다음 단계로 (기본 true — 밤새 도는 대기열이 한 단계 때문에 멈추지 않게). +상태는 outputs/jobs//state.json — 같은 이름·같은 명령으로 끝난 단계는 다시 실행하지 않는다 (이어 하기). +단계마다 로그 outputs/jobs//<단계>.log. +""" + +from __future__ import annotations + +import itertools +import json +import logging +import re +import shlex +import shutil +import subprocess +import time +from pathlib import Path + +LOG = logging.getLogger(__name__) +_VAR = re.compile(r"\$\{(\w+(?:\.\w+)*)\}") + + +def _subst(x, env: dict): + if isinstance(x, str): + def one(m): + v = env + for part in m.group(1).split("."): # ${e.args} — foreach 값이 객체일 때 + if not isinstance(v, dict) or part not in v: + raise ValueError(f"정의되지 않은 변수: ${{{m.group(1)}}}") + v = v[part] + return str(v) + return _VAR.sub(one, x) + if isinstance(x, list): + return [_subst(v, env) for v in x] + if isinstance(x, dict): + return {k: _subst(v, env) for k, v in x.items()} + return x + + +def expand(job: dict) -> list[dict]: + """foreach 를 펼치고 변수를 채운 단계 목록.""" + base_env = {k: _subst(v, {}) if isinstance(v, str) and "${" not in v else v for k, v in job.get("vars", {}).items()} + # vars 끼리 참조할 수 있게 한 번 더 + base_env = {k: _subst(v, base_env) for k, v in base_env.items()} + out = [] + for st in job["steps"]: + loops = st.get("foreach", {}) + keys = list(loops) + for combo in itertools.product(*(loops[k] for k in keys)) if keys else [()]: + env = dict(base_env, **dict(zip(keys, combo))) + s = _subst({k: v for k, v in st.items() if k != "foreach"}, env) + run = s["run"] + s["run"] = shlex.split(run) if isinstance(run, str) else list(run) + s.setdefault("name", " ".join(s["run"][:3])) + out.append(s) + names = [s["name"] for s in out] + dup = {n for n in names if names.count(n) > 1} + if dup: + raise ValueError(f"단계 이름이 겹칩니다 (상태 기록 키): {sorted(dup)}") + return out + + +def run_job(path: Path, dry: bool = False, log=LOG.info) -> int: + from nusim.config import load + from nusim.paths import output_dir, workspace_root + + job = load(path) + steps = expand(job) + wdir = output_dir() / "jobs" / job["name"] + wdir.mkdir(parents=True, exist_ok=True) + state_path = wdir / "state.json" + state = json.loads(state_path.read_text()) if state_path.exists() else {} + fails = 0 + for k, st in enumerate(steps): + name, cmd = st["name"], st["run"] + if state.get(name, {}).get("state") == "done" and state[name].get("run") == cmd: + log(f"[{k + 1}/{len(steps)}] {name} — 이미 끝남, 건너뜀") + continue + log(f"[{k + 1}/{len(steps)}] {name}: nusim {' '.join(cmd)}") + if dry: + continue + for c in st.get("clean", []): + p = Path(c) if Path(c).is_absolute() else workspace_root() / c + if p.exists(): + shutil.rmtree(p) + t0 = time.time() + safe = re.sub(r"[^\w.-]+", "_", name) + with open(wdir / f"{safe}.log", "w") as f: + r = subprocess.run(["nusim", *cmd], stdout=f, stderr=subprocess.STDOUT, cwd=workspace_root()) + rec = {"run": cmd, "state": "done" if r.returncode == 0 else "failed", "returncode": r.returncode, + "seconds": round(time.time() - t0)} + state[name] = rec + state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False)) + log(f" {rec['state']} ({rec['seconds'] / 60:.0f}분)") + if r.returncode != 0: + fails += 1 + if not st.get("continue_on_error", job.get("continue_on_error", True)): + break + return 1 if fails else 0 diff --git a/src/nusim/nusim/log.py b/src/nusim/nusim/log.py new file mode 100644 index 0000000000000000000000000000000000000000..2e1ed1680d25146d3601b9503a42cefc3b91c94b --- /dev/null +++ b/src/nusim/nusim/log.py @@ -0,0 +1,67 @@ +"""프로젝트 공용 로그. + +모든 모듈은 `logging.getLogger(__name__)` (= "nusim.recon.train" 같은 이름)로 남긴다. +`setup()` 이 한 번 불리면: + 콘솔 HH:MM:SS 메시지 (사람이 보는 진행 상황) + 파일 날짜 시각.ms 레벨 모듈 | 메시지 (outputs/logs/<명령>_<시각>.log — 나중에 추적용) +`run_log(path)` 는 학습 같은 긴 작업의 결과 디렉터리에 같은 로그를 한 벌 더 남긴다. + +함수들이 받는 `log=` 인자(진행 메시지 콜백)의 기본값은 모듈 로거의 info 다 — print 가 아니다. +""" + +from __future__ import annotations + +import contextlib +import logging +import sys +import time +from pathlib import Path + +ROOT = "nusim" +_FILE_FMT = "%(asctime)s.%(msecs)03d %(levelname)-5s %(name)s | %(message)s" +_CONSOLE_FMT = "%(asctime)s %(message)s" + + +def get(name: str) -> logging.Logger: + return logging.getLogger(name if name.startswith(ROOT) else f"{ROOT}.{name}") + + +def setup(log_dir: Path | None = None, tag: str = "nusim", level: int = logging.INFO, + debug_file: bool = True) -> Path | None: + """콘솔 + 파일 핸들러를 건다. 두 번 불려도 핸들러가 중복되지 않는다. 로그 파일 경로를 돌려준다.""" + root = logging.getLogger(ROOT) + root.setLevel(logging.DEBUG if debug_file else level) + root.propagate = False + for h in list(root.handlers): + root.removeHandler(h) + con = logging.StreamHandler(sys.stdout) + con.setLevel(level) + con.setFormatter(logging.Formatter(_CONSOLE_FMT, "%H:%M:%S")) + root.addHandler(con) + path = None + if log_dir is not None: + log_dir.mkdir(parents=True, exist_ok=True) + path = log_dir / f"{tag}_{time.strftime('%Y%m%d-%H%M%S')}.log" + fh = logging.FileHandler(path, encoding="utf-8") + fh.setLevel(logging.DEBUG if debug_file else level) + fh.setFormatter(logging.Formatter(_FILE_FMT, "%Y-%m-%d %H:%M:%S")) + root.addHandler(fh) + return path + + +@contextlib.contextmanager +def run_log(path: Path): + """with run_log(out_dir / "run.log"): ... — 그 안의 nusim 로그를 이 파일에도 (DEBUG 포함) 남긴다.""" + path.parent.mkdir(parents=True, exist_ok=True) + fh = logging.FileHandler(path, encoding="utf-8") + fh.setLevel(logging.DEBUG) + fh.setFormatter(logging.Formatter(_FILE_FMT, "%Y-%m-%d %H:%M:%S")) + root = logging.getLogger(ROOT) + if not root.handlers: # setup() 없이 라이브러리로 쓰인 경우에도 콘솔에는 나오게 + setup() + root.addHandler(fh) + try: + yield path + finally: + root.removeHandler(fh) + fh.close() diff --git a/src/nusim/nusim/paths.py b/src/nusim/nusim/paths.py new file mode 100644 index 0000000000000000000000000000000000000000..0e0c33f962a610d25d49d07db8e3bd3e25af5d88 --- /dev/null +++ b/src/nusim/nusim/paths.py @@ -0,0 +1,154 @@ +"""데이터셋/작업공간 경로 해석. + +컨테이너 안에서는 nuScenes 가 /data/nuscenes 에 읽기전용으로 마운트되고, +컨테이너 밖(호스트)에서는 NAS 원본 경로를 그대로 쓴다. +코드가 두 경우를 모두 견디도록 여기 한 곳에서만 결정한다. + +우선순위: 명시 인자 > 환경변수 NUSCENES_ROOT > config/paths.yaml > 알려진 후보 +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +# 컨테이너 마운트 경로가 먼저, 그 다음이 호스트 NAS 경로. +_ROOT_CANDIDATES = ( + "/data/nuscenes", + "/home/mm/nas2/nuscenes_original_junhan/nuscenes/original/full", +) + +# 루트가 맞는지 판정하는 기준. 이 세 가지가 전부 있어야 우리가 쓰는 파이프라인이 돈다. +_ROOT_MARKERS = ("v1.0-trainval", "samples", "maps") + + +def workspace_root() -> Path: + """프로젝트 루트. 컨테이너에서는 /workspace, 밖에서는 소스 트리를 거슬러 찾는다.""" + if env := os.environ.get("VKGS_WORKSPACE"): + return Path(env) + # .../src/nusim/nusim/paths.py → 3단계 위가 프로젝트 루트 + return Path(__file__).resolve().parents[3] + + +def _looks_like_nuscenes(p: Path) -> bool: + return all((p / m).exists() for m in _ROOT_MARKERS) + + +def nuscenes_root(explicit: str | os.PathLike | None = None) -> Path: + """nuScenes 데이터셋 루트를 돌려준다. 못 찾으면 무엇을 찾았는지 알려주며 실패한다.""" + tried: list[str] = [] + + for cand in (explicit, os.environ.get("NUSCENES_ROOT")): + if not cand: + continue + p = Path(cand) + if _looks_like_nuscenes(p): + return p + tried.append(f"{p} (지정됐으나 {_ROOT_MARKERS} 중 일부가 없음)") + + cfg = workspace_root() / "config" / "paths.yaml" + if cfg.exists(): + try: + import yaml + + data = yaml.safe_load(cfg.read_text()) or {} + if cand := data.get("nuscenes_root"): + p = Path(cand) + if _looks_like_nuscenes(p): + return p + tried.append(f"{p} (config/paths.yaml)") + except Exception as e: # yaml 이 없거나 깨진 경우 — 치명적이지 않다 + tried.append(f"{cfg} (읽기 실패: {e})") + + for cand in _ROOT_CANDIDATES: + p = Path(cand) + if _looks_like_nuscenes(p): + return p + tried.append(str(p)) + + raise FileNotFoundError( + "nuScenes 루트를 찾지 못했습니다. 시도한 경로:\n " + + "\n ".join(tried) + + "\n\n루트에는 다음이 있어야 합니다: " + + ", ".join(_ROOT_MARKERS) + + "\n환경변수 NUSCENES_ROOT 로 지정하거나 config/paths.yaml 을 고치세요." + ) + + +@dataclass(frozen=True) +class DatasetPaths: + """nuScenes 루트 아래의 하위 경로 모음. 경로 문자열을 코드 곳곳에 흩지 않기 위함.""" + + root: Path + version: str = "v1.0-trainval" + # 카메라 이미지만 다른 곳에서 읽을 때 (같은 상대 경로) — 예: 밤·비 → 낮으로 바꾼 영상 (nusim.aug.translate). + # 라이다·메타·지도는 그대로 root 에서. + image_root: Path | None = None + + @property + def images(self) -> Path: + return self.image_root or self.root + + @property + def meta(self) -> Path: + """scene.json / sample_data.json 등 테이블이 있는 곳.""" + return self.root / self.version + + @property + def samples(self) -> Path: + """키프레임 센서 데이터 (2 Hz).""" + return self.root / "samples" + + @property + def sweeps(self) -> Path: + """비키프레임 센서 데이터 (카메라 12 Hz). 3DGS 재구성에 핵심이다 — + 키프레임만 쓰면 뷰가 너무 성겨서 가우시안이 수렴하지 않는다.""" + return self.root / "sweeps" + + @property + def maps(self) -> Path: + return self.root / "maps" + + @property + def map_expansion(self) -> Path: + """singapore-onenorth.json 등 벡터 맵. 차선 그래프의 출처다.""" + return self.root / "maps" / "expansion" + + @property + def can_bus(self) -> Path: + """scene-XXXX_pose.json 등. ego_pose.json 보다 훨씬 조밀한 자차 상태로, + closed-loop 차량 모델 튜닝에 쓴다.""" + return self.root / "can_bus" + + def check(self) -> dict[str, tuple[bool, Path]]: + """각 하위 경로의 존재 여부. 진단(vkgs-doctor)에서 쓴다.""" + return { + name: (path.exists(), path) + for name, path in ( + ("meta", self.meta), + ("samples", self.samples), + ("sweeps", self.sweeps), + ("maps", self.maps), + ("map_expansion", self.map_expansion), + ("can_bus", self.can_bus), + ) + } + + +def dataset(explicit: str | os.PathLike | None = None, version: str = "v1.0-trainval") -> DatasetPaths: + return DatasetPaths(root=nuscenes_root(explicit), version=version) + + +def cache_dir() -> Path: + """파생 산출물(씬 인덱스 등). NAS 가 아니라 로컬 디스크여야 한다 — + NFS 왕복이 인덱스 조회보다 비싸면 캐시의 의미가 없다.""" + d = Path(os.environ.get("VKGS_CACHE", workspace_root() / "cache")) + d.mkdir(parents=True, exist_ok=True) + return d + + +def output_dir() -> Path: + d = Path(os.environ.get("VKGS_OUTPUT", workspace_root() / "outputs")) + d.mkdir(parents=True, exist_ok=True) + return d diff --git a/src/nusim/nusim/render/__init__.py b/src/nusim/nusim/render/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..12d4d217d2739da3c4364852d8bdb34afa7849ba --- /dev/null +++ b/src/nusim/nusim/render/__init__.py @@ -0,0 +1,9 @@ +"""Vulkan 렌더러(src/vkgs/vkgs_viewer) 와의 접점. + + export.py 카메라 리그·맵 선·평가 뷰를 뷰어가 읽는 텍스트 파일로 + compare.py 같은 카메라에서 학습기(gsplat) 렌더 vs Vulkan 렌더 픽셀 비교 (roadmap 2단계 기준) + + nusim render export scene-0003 # rig.txt / map.txt / test_views.txt + nusim render compare scene-0003 # 오프스크린 렌더 후 PSNR / 평균 오차 + nusim render view scene-0003 # 창 모드 뷰어 실행 +""" diff --git a/src/nusim/pyproject.toml b/src/nusim/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..bf5518d91a4a8b835484937aaeaa6a10d9b1f566 --- /dev/null +++ b/src/nusim/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "nusim" +version = "0.1.0" +description = "nuScenes 싱가포르 맵 기반 Vulkan 3DGS closed-loop 시뮬레이터" +requires-python = ">=3.10" + +# 실제 설치는 docker/requirements.txt 가 담당한다 (Dockerfile 이 --no-deps 로 건다). +# 여기 목록은 컨테이너 밖에서 쓸 때를 위한 문서 겸 사양이다. +dependencies = [ + "numpy<2.0", "scipy", "Pillow", "opencv-python-headless", + "pyquaternion", "shapely<2.0", "cachetools", "matplotlib", + "plyfile", "pyyaml", "typer", "rich", "tqdm", +] + +[project.scripts] +vkgs-doctor = "nusim.cli.doctor:main" +nusim = "nusim.cli.main:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["nusim*"] diff --git a/src/vkgs/CMakeLists.txt b/src/vkgs/CMakeLists.txt new file mode 100644 index 0000000000000000000000000000000000000000..93b886807d8cbbf8b724832bea93169a1a1f73ee --- /dev/null +++ b/src/vkgs/CMakeLists.txt @@ -0,0 +1,168 @@ +cmake_minimum_required(VERSION 3.22) +project(vkgs LANGUAGES C CXX) + +# 3DGS compute 래스터라이저는 C++20 의 designated initializer 와 를 쓴다. +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # clangd 용 + +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE RelWithDebInfo) +endif() + +find_package(Vulkan REQUIRED) +message(STATUS "Vulkan: ${Vulkan_LIBRARY} (헤더 ${Vulkan_INCLUDE_DIR})") + +# ---------------------------------------------------------------- 셰이더 컴파일 +# glslc(shaderc) 를 선호하되 없으면 glslangValidator 로 폴백한다. +# jammy 에서 glslc 는 별도 패키지라 항상 있다고 가정할 수 없다. +# FindVulkan 이 glslc 를 찾아 주면 그걸 쓰고, 아니면 PATH 에서 찾는다. +if(Vulkan_GLSLC_EXECUTABLE) + set(GLSLC ${Vulkan_GLSLC_EXECUTABLE}) +else() + find_program(GLSLC glslc) +endif() +find_program(GLSLANG glslangValidator) + +if(GLSLC) + # -I: gs_common.glsl 같은 공통 헤더를 #include 하기 위함 + set(SHADER_CMD ${GLSLC} --target-env=vulkan1.2 -O -I ${CMAKE_CURRENT_SOURCE_DIR}/shaders) + message(STATUS "셰이더 컴파일러: ${GLSLC}") +elseif(GLSLANG) + # -V 를 반드시 준다. --target-env 만으로 SPIR-V 가 나온다고 기대하면 안 된다 — + # 버전에 따라 검증만 하고 -o 를 무시해 빈 파일이 남는다. + set(SHADER_CMD ${GLSLANG} -V --target-env vulkan1.2 -I${CMAKE_CURRENT_SOURCE_DIR}/shaders) + message(STATUS "셰이더 컴파일러: ${GLSLANG} (glslc 없음)") +else() + message(FATAL_ERROR "glslc 도 glslangValidator 도 없습니다. glslang-tools 를 설치하세요.") +endif() +set(SHADER_OUT_FLAG -o) + +set(SPIRV_DIR ${CMAKE_BINARY_DIR}/shaders) +file(MAKE_DIRECTORY ${SPIRV_DIR}) + +# GLSL 하나를 SPIR-V 로 굽는다. 결과는 빌드 디렉터리의 shaders/ 아래에 놓인다. +function(vkgs_add_shader SRC) + get_filename_component(NAME ${SRC} NAME) + set(OUT ${SPIRV_DIR}/${NAME}.spv) + add_custom_command( + OUTPUT ${OUT} + COMMAND ${SHADER_CMD} ${CMAKE_CURRENT_SOURCE_DIR}/${SRC} ${SHADER_OUT_FLAG} ${OUT} + # 컴파일러가 조용히 빈 파일을 남기는 경우가 있어 여기서 걸러낸다. + COMMAND ${CMAKE_COMMAND} -E cat ${OUT} > /dev/null + DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${SRC} ${CMAKE_CURRENT_SOURCE_DIR}/shaders/gs_common.glsl ${CMAKE_CURRENT_SOURCE_DIR}/shaders/ibl_common.glsl + ${CMAKE_CURRENT_SOURCE_DIR}/shaders/depth_common.glsl ${CMAKE_CURRENT_SOURCE_DIR}/shaders/bindless.glsl + ${CMAKE_CURRENT_SOURCE_DIR}/shaders/gs_radix_common.glsl ${CMAKE_CURRENT_SOURCE_DIR}/shaders/weather_common.glsl + COMMENT "SPIR-V: ${NAME}" + VERBATIM) + list(APPEND VKGS_SPIRV ${OUT}) + set(VKGS_SPIRV ${VKGS_SPIRV} PARENT_SCOPE) +endfunction() + +vkgs_add_shader(shaders/smoke.comp) +vkgs_add_shader(shaders/gs_preprocess.comp) +vkgs_add_shader(shaders/gs_sort.comp) +vkgs_add_shader(shaders/gs_radix_hist.comp) +vkgs_add_shader(shaders/gs_radix_scan.comp) +vkgs_add_shader(shaders/gs_radix_scatter.comp) +vkgs_add_shader(shaders/gs_splat.vert) +vkgs_add_shader(shaders/gs_splat.frag) +vkgs_add_shader(shaders/line.vert) +vkgs_add_shader(shaders/line.frag) +vkgs_add_shader(shaders/env.vert) +vkgs_add_shader(shaders/env.frag) +vkgs_add_shader(shaders/pbr.vert) +vkgs_add_shader(shaders/pbr.frag) +vkgs_add_shader(shaders/weather.comp) +vkgs_add_shader(shaders/fxaa.comp) +vkgs_add_shader(shaders/camera_mode.comp) +vkgs_add_shader(shaders/precip.vert) +vkgs_add_shader(shaders/precip.frag) +vkgs_add_shader(shaders/sky_sim.frag) +vkgs_add_shader(shaders/pbr_gltf.vert) +vkgs_add_shader(shaders/pbr_gltf.frag) +vkgs_add_shader(shaders/shadow.vert) +vkgs_add_shader(shaders/shadow.frag) + +# 셰이더 그래프 (configs/shader_graphs/*.json) → scripts/shader_graph.py 가 GLSL 로 펼침 → SPIR-V. +# 그래프 JSON·노드 라이브러리(shaders/nodes/*.glsl)·생성기가 바뀌면 다시 만든다. 매개변수 표(.params.json)는 +# SPIR-V 옆에 놓여 뷰어가 슬라이더·초깃값에 쓴다. +find_program(VKGS_PYTHON NAMES python3 python REQUIRED) +set(GRAPH_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../configs/shader_graphs) +set(GRAPH_GEN ${CMAKE_CURRENT_SOURCE_DIR}/../../scripts/shader_graph.py) +file(GLOB VKGS_SHADER_GRAPHS CONFIGURE_DEPENDS ${GRAPH_DIR}/*.json) +file(GLOB VKGS_NODE_LIBS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/shaders/nodes/*.glsl) +foreach(G ${VKGS_SHADER_GRAPHS}) + get_filename_component(GNAME ${G} NAME_WE) + set(GSRC ${SPIRV_DIR}/gen/${GNAME}.comp) + set(GOUT ${SPIRV_DIR}/${GNAME}.comp.spv) + add_custom_command( + OUTPUT ${GSRC} ${SPIRV_DIR}/${GNAME}.params.json + COMMAND ${VKGS_PYTHON} ${GRAPH_GEN} ${G} --shaders ${CMAKE_CURRENT_SOURCE_DIR}/shaders --out ${SPIRV_DIR}/gen + COMMAND ${CMAKE_COMMAND} -E copy ${SPIRV_DIR}/gen/${GNAME}.params.json ${SPIRV_DIR}/${GNAME}.params.json + DEPENDS ${G} ${GRAPH_GEN} ${VKGS_NODE_LIBS} + COMMENT "셰이더 그래프: ${GNAME}" + VERBATIM) + add_custom_command( + OUTPUT ${GOUT} + COMMAND ${SHADER_CMD} ${GSRC} ${SHADER_OUT_FLAG} ${GOUT} + DEPENDS ${GSRC} ${CMAKE_CURRENT_SOURCE_DIR}/shaders/bindless.glsl + COMMENT "SPIR-V: ${GNAME}.comp (셰이더 그래프)" + VERBATIM) + list(APPEND VKGS_SPIRV ${GOUT} ${SPIRV_DIR}/${GNAME}.params.json) +endforeach() +add_custom_target(vkgs_shaders ALL DEPENDS ${VKGS_SPIRV}) + +# ---------------------------------------------------------------- 실행파일 +# vkgs_probe: 컨테이너의 Vulkan 경로가 진짜로 동작하는지 끝까지 확인한다. +# 장치 열거 → 큐 생성 → SPIR-V 로드 → 컴퓨트 디스패치 → 결과 읽기 +# 이 중 하나라도 조용히 실패하면 렌더러는 검은 화면만 낸다. +add_executable(vkgs_probe src/probe.cpp) +target_link_libraries(vkgs_probe PRIVATE Vulkan::Vulkan) +target_compile_definitions(vkgs_probe PRIVATE VKGS_SPIRV_DIR="${SPIRV_DIR}") +add_dependencies(vkgs_probe vkgs_shaders) + +# vkgs_voxcarve: 레이캐스트 센서용 복셀 월드의 빈 공간 깎기 (nusim/sim/voxels.py 가 부른다) +add_executable(vkgs_voxcarve src/voxcarve.cpp) +find_package(Threads REQUIRED) +target_link_libraries(vkgs_voxcarve PRIVATE Threads::Threads) + +# vkgs_viewer: 3DGS .ply 를 그린다 (오프스크린 PNG / GLFW 창). roadmap 2a. +find_package(glfw3 3.3 REQUIRED) +# Dear ImGui (MIT) — 뷰어 안 슬라이더 UI (합성 날씨·조도). third_party/imgui (v1.91.9) +set(IMGUI_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../third_party/imgui) +# SPIRV-Reflect (Khronos, Apache-2.0) — 셰이더 리플렉션 (src/shader_reflect.*) +set(SPVR_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../third_party/SPIRV-Reflect) +add_executable(vkgs_viewer src/viewer.cpp src/ply.cpp src/gltf.cpp src/shader_reflect.cpp src/render_graph.cpp ${SPVR_DIR}/spirv_reflect.c + ${IMGUI_DIR}/imgui.cpp ${IMGUI_DIR}/imgui_draw.cpp ${IMGUI_DIR}/imgui_tables.cpp + ${IMGUI_DIR}/imgui_widgets.cpp ${IMGUI_DIR}/backends/imgui_impl_glfw.cpp + ${IMGUI_DIR}/backends/imgui_impl_vulkan.cpp) +# GLFW 헤더가 OpenGL 헤더(GL/gl.h)를 끌어오지 않게 — 우리는 Vulkan 만 쓴다 +set_source_files_properties(${IMGUI_DIR}/backends/imgui_impl_glfw.cpp PROPERTIES COMPILE_DEFINITIONS GLFW_INCLUDE_NONE) +target_include_directories(vkgs_viewer PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../third_party/stb + ${CMAKE_CURRENT_SOURCE_DIR}/../../third_party/cgltf + ${IMGUI_DIR} ${IMGUI_DIR}/backends + ${CMAKE_CURRENT_SOURCE_DIR}/../../third_party + ${SPVR_DIR}) +target_link_libraries(vkgs_viewer PRIVATE Vulkan::Vulkan glfw) +target_compile_definitions(vkgs_viewer PRIVATE VKGS_SPIRV_DIR="${SPIRV_DIR}" + VKGS_CONFIG_DEFAULT="${CMAKE_CURRENT_SOURCE_DIR}/../../configs/sim.json" + VKGS_RENDER_GRAPH_DEFAULT="${CMAKE_CURRENT_SOURCE_DIR}/../../configs/render_graph.json" + VKGS_TRACY_PROFILER_DEFAULT="${CMAKE_BINARY_DIR}/tracy-profiler/tracy-profiler") +add_dependencies(vkgs_viewer vkgs_shaders) + +# Tracy 프로파일러 클라이언트 (third_party/tracy v0.11.1, BSD-3). ON_DEMAND: 프로파일러가 붙어 있을 때만 기록 — +# 평소 실행 비용은 거의 없다. 프로파일러 GUI·캡처 도구 빌드는 scripts/build_tracy.sh. +option(VKGS_TRACY "Tracy 프로파일링 (CPU·GPU 구간)" ON) +if(VKGS_TRACY) + set(TRACY_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../third_party/tracy) + target_sources(vkgs_viewer PRIVATE ${TRACY_DIR}/public/TracyClient.cpp) + target_include_directories(vkgs_viewer PRIVATE ${TRACY_DIR}/public) + target_compile_definitions(vkgs_viewer PRIVATE TRACY_ENABLE TRACY_ON_DEMAND) + target_link_libraries(vkgs_viewer PRIVATE dl pthread) +else() + target_include_directories(vkgs_viewer PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../third_party/tracy/public) +endif() + +install(TARGETS vkgs_probe vkgs_viewer vkgs_voxcarve RUNTIME DESTINATION bin) +install(DIRECTORY ${SPIRV_DIR}/ DESTINATION share/vkgs/shaders) diff --git a/src/vkgs/shaders/bindless.glsl b/src/vkgs/shaders/bindless.glsl new file mode 100644 index 0000000000000000000000000000000000000000..6443dd0c6d1e7fde11713f14b654862dd9f946ff --- /dev/null +++ b/src/vkgs/shaders/bindless.glsl @@ -0,0 +1,20 @@ +// 전역 bindless descriptor set (set 0) — viewer.cpp 의 Bindless 와 같은 binding·슬롯 번호. +// 모든 파이프라인이 이 set 하나와 같은 push constant 범위를 쓴다. 리소스는 '배열 + 번호' 로 고른다: +// binding 0 SSBO[] 셰이더마다 필요한 타입으로 같은 binding 을 별칭 선언한다 +// binding 1 UBO[] +// binding 2 sampler2D[] glTF 재질 텍스처, BRDF LUT +// binding 3 samplerCube[] IBL specular·irradiance, 학습된 하늘 +// binding 4 image2D[] 4채널 렌더 타겟·날씨 결과·배경·강수 (형식 없는 storage — 형식은 렌더 그래프 JSON) +// binding 5 image2D[] 1~2채널 거리 누적 (RG: Σzα, Σα — depth_common.glsl) +// 하나뿐인 리소스는 고정 슬롯 (아래 상수), 여러 개인 리소스(재질·텍스처·큐브)는 push constant 나 전역 상수의 번호. +#extension GL_EXT_nonuniform_qualifier : require + +const uint kSsboGlobals = 0u, kSsboGauss = 1u, kSsboSplats = 2u, kSsboKeys = 3u, kSsboDraw = 4u; +const uint kUboCamera = 0u, kUboWeather = 1u; +const uint kImgTarget = 0u, kImgPost = 1u; // rgba32f +const uint kImgDepthAcc = 0u; // r32f + +layout(set = 0, binding = 2) uniform sampler2D gTex2D[]; +layout(set = 0, binding = 3) uniform samplerCube gCube[]; +// 전역 상수 (SSBO 슬롯 0): ibl = (specular 큐브, irradiance 큐브, BRDF LUT 2D, 학습된 하늘 큐브) 번호 +layout(std430, set = 0, binding = 0) readonly buffer GlobalsBuf { ivec4 ibl; ivec4 pad; } gGlobals[]; diff --git a/src/vkgs/shaders/depth_common.glsl b/src/vkgs/shaders/depth_common.glsl new file mode 100644 index 0000000000000000000000000000000000000000..23feab27be7edce87e248fd4df684d1063f510d7 --- /dev/null +++ b/src/vkgs/shaders/depth_common.glsl @@ -0,0 +1,19 @@ +// 거리 누적 버퍼 (렌더 패스 색 첨부 1, RG32F) — 날씨 후처리(weather.comp)·강수 입자(precip.frag)가 쓴다. +// 그리는 것마다 (z·α, α) 를 색과 같은 premultiplied over 로 쌓는다: +// R = Σ z·α·T (카메라 z, m), G = Σ α·T = 배경을 뺀 덮임 정도 (0 = 배경만, 1 = 불투명한 것이 다 가림) +// 배경(하늘·환경맵)은 (0, 0) 을 α = 1 로 쓴다 — 앞의 누적을 지우고 덮임 0 에서 시작. 지운 값도 0. +// 표면 깊이 = R / G, 배경이 보이는 비율 = 1 − G (accum_depth / accum_cover). +// (2026-10-05 전까지는 R 하나에 배경 1000 m 를 섞어 '기대 깊이' 로 썼다 — 반투명이 3%만 남아도 30 m 가 더해져 +// 빗방울 가림·안개·노면 판정이 틀렸다.) +const float kNear = 0.2, kFar = 2000.0; // viewer.cpp RenderCam 기본값과 같다 +const float kSkyDepth = 1000.0; + +// Vulkan [0,1] 깊이(gl_FragCoord.z) → 카메라 z (m). viewer.cpp projection() 의 역. +float linear_depth(float zndc) { + return kFar * kNear / (kFar - zndc * (kFar - kNear)); +} + +// 누적 버퍼 값 (R, G) → 표면 카메라 z (m). 덮임이 거의 없으면 kSkyDepth. +float accum_depth(vec2 rg) { return rg.y > 0.02 ? rg.x / rg.y : kSkyDepth; } +float accum_cover(vec2 rg) { return clamp(rg.y, 0.0, 1.0); } + diff --git a/src/vkgs/shaders/env.frag b/src/vkgs/shaders/env.frag new file mode 100644 index 0000000000000000000000000000000000000000..7f21b5278cb2b0f11f67345e7c2a50acad78fb3a --- /dev/null +++ b/src/vkgs/shaders/env.frag @@ -0,0 +1,21 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +// 환경맵 배경. 3DGS 가 비어 있는(알파 < 1) 곳으로 보인다 — 스플랫이 뒤에 그려지며 덮는다. +#include "ibl_common.glsl" +#include "depth_common.glsl" +layout(location = 0) in vec2 vPix; +layout(location = 0) out vec4 outColor; +layout(push_constant) uniform PC { + mat4 c2w_rot; + vec4 intr; + vec4 size; + ivec4 idx; // x = 배경으로 쓸 큐브 번호 (IBL specular 또는 학습된 하늘) +} pc; +void main() { + vec3 dc = vec3((vPix.x - pc.intr.z) / pc.intr.x, (vPix.y - pc.intr.w) / pc.intr.y, 1.0); + vec3 dw = normalize(mat3(pc.c2w_rot) * dc); + vec3 lin = textureLod(gCube[pc.idx.x], dw, pc.size.z).rgb; + // size.w = 1: 학습된 하늘(sky.ktx2) — LDR 정준 색을 선형으로 담았을 뿐이라 하이라이트 압축을 하지 않는다 + vec3 c = pc.size.w > 0.5 ? lin : compress_highlights(lin); + outColor = vec4(linear_to_srgb(c), 1.0); +} diff --git a/src/vkgs/shaders/env.vert b/src/vkgs/shaders/env.vert new file mode 100644 index 0000000000000000000000000000000000000000..b26b76a18aabd531b15ada8fd22bbb7d784bc3ec --- /dev/null +++ b/src/vkgs/shaders/env.vert @@ -0,0 +1,14 @@ +#version 450 +// 화면을 덮는 삼각형 하나. 픽셀 → 시선 방향은 프래그먼트에서. +layout(location = 0) out vec2 vPix; +layout(push_constant) uniform PC { + mat4 c2w_rot; // 카메라 → 씬 회전 (OpenCV 축) + vec4 intr; // fx fy cx cy + vec4 size; // w h lod - + ivec4 idx; // env.frag 참고 +} pc; +void main() { + vec2 uv = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); + gl_Position = vec4(uv * 2.0 - 1.0, 1.0, 1.0); + vPix = uv * pc.size.xy; +} diff --git a/src/vkgs/shaders/fxaa.comp b/src/vkgs/shaders/fxaa.comp new file mode 100644 index 0000000000000000000000000000000000000000..74e66dafa6b1c39d6be41d37cc219e32e61be623 --- /dev/null +++ b/src/vkgs/shaders/fxaa.comp @@ -0,0 +1,80 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +#extension GL_EXT_shader_image_load_formatted : require +// FXAA (Lottes 3.11 품질 프리셋을 줄인 것) — 화면 공간 안티에일리어싱, 컴퓨트 한 패스. +// MSAA 는 스플랫 블렌딩을 샘플 수만큼 곱하므로(4× 이면 래스터 4배) 쓰지 않는다. 3DGS 는 원래 부드럽고, +// 계단은 메시(차량)·맵 선 가장자리에 생긴다 — 그 가장자리만 루마 대비로 찾아 그 방향으로 섞는다. +// 입력 post(sRGB) → 출력 color(이 시점엔 다 쓴 장면 색 이미지를 빌려 쓴다) → 렌더 그래프가 다시 post 로 복사. +layout(local_size_x = 8, local_size_y = 8) in; +#include "bindless.glsl" +layout(set = 0, binding = 4) uniform image2D gImgF4[]; +#define uIn gImgF4[kImgPost] +#define uOut gImgF4[kImgTarget] + +const float EDGE_MIN = 1.0 / 24.0; // 어두운 곳에서 이보다 작은 대비는 무시 +const float EDGE_REL = 1.0 / 8.0; // 주변 최대 루마 대비 상대 임계값 +const float SUBPIX = 0.75; // 픽셀 이하 계단 완화 정도 +const int STEPS = 8; + +ivec2 gSize; +vec3 at(ivec2 p) { return imageLoad(uIn, clamp(p, ivec2(0), gSize - 1)).rgb; } +// 양선형 샘플 (storage image 라 직접 섞는다) +vec3 bilerp(vec2 p) { + p -= 0.5; + ivec2 i = ivec2(floor(p)); + vec2 f = p - vec2(i); + return mix(mix(at(i), at(i + ivec2(1, 0)), f.x), mix(at(i + ivec2(0, 1)), at(i + ivec2(1, 1)), f.x), f.y); +} +float luma(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); } + +void main() { + gSize = imageSize(uIn); + ivec2 p = ivec2(gl_GlobalInvocationID.xy); + if (p.x >= gSize.x || p.y >= gSize.y) return; + vec4 src = imageLoad(uIn, p); + float lM = luma(src.rgb); + float lN = luma(at(p + ivec2(0, -1))), lS = luma(at(p + ivec2(0, 1))); + float lW = luma(at(p + ivec2(-1, 0))), lE = luma(at(p + ivec2(1, 0))); + float lo = min(lM, min(min(lN, lS), min(lW, lE))), hi = max(lM, max(max(lN, lS), max(lW, lE))); + float range = hi - lo; + if (range < max(EDGE_MIN, hi * EDGE_REL)) { imageStore(uOut, p, src); return; } + + float lNW = luma(at(p + ivec2(-1, -1))), lNE = luma(at(p + ivec2(1, -1))); + float lSW = luma(at(p + ivec2(-1, 1))), lSE = luma(at(p + ivec2(1, 1))); + // 픽셀 이하 혼합량 + float avg = (2.0 * (lN + lS + lW + lE) + lNW + lNE + lSW + lSE) / 12.0; + float sub = clamp(abs(avg - lM) / range, 0.0, 1.0); + sub = smoothstep(0.0, 1.0, sub); + sub = sub * sub * SUBPIX; + // 가장자리 방향: 가로(위아래로 바뀜) 또는 세로 + float eH = abs(lNW + lNE - 2.0 * lN) + 2.0 * abs(lW + lE - 2.0 * lM) + abs(lSW + lSE - 2.0 * lS); + float eV = abs(lNW + lSW - 2.0 * lW) + 2.0 * abs(lN + lS - 2.0 * lM) + abs(lNE + lSE - 2.0 * lE); + bool horz = eH >= eV; + float l1 = horz ? lN : lW, l2 = horz ? lS : lE; + float g1 = abs(l1 - lM), g2 = abs(l2 - lM); + bool steep1 = g1 >= g2; + float grad = 0.25 * max(g1, g2); + vec2 stepN = horz ? vec2(0.0, steep1 ? -1.0 : 1.0) : vec2(steep1 ? -1.0 : 1.0, 0.0); + float lEdge = 0.5 * (lM + (steep1 ? l1 : l2)); + vec2 c = vec2(p) + 0.5 + 0.5 * stepN; + vec2 along = horz ? vec2(1.0, 0.0) : vec2(0.0, 1.0); + // 가장자리를 따라 양쪽 끝 찾기 + vec2 a = c - along, b = c + along; + float da = luma(bilerp(a)) - lEdge, db = luma(bilerp(b)) - lEdge; + bool doneA = abs(da) >= grad, doneB = abs(db) >= grad; + for (int k = 0; k < STEPS && !(doneA && doneB); ++k) { + float s = k < 3 ? 1.0 : 2.0; + if (!doneA) { a -= along * s; da = luma(bilerp(a)) - lEdge; doneA = abs(da) >= grad; } + if (!doneB) { b += along * s; db = luma(bilerp(b)) - lEdge; doneB = abs(db) >= grad; } + } + vec2 here = vec2(p) + 0.5; + float distA = horz ? here.x - a.x : here.y - a.y, distB = horz ? b.x - here.x : b.y - here.y; + bool nearA = distA < distB; + float dist = min(distA, distB), len = distA + distB; + bool smaller = lM - lEdge < 0.0; + bool ok = ((nearA ? da : db) < 0.0) != smaller; + float off = ok ? 0.5 - dist / len : 0.0; + off = max(off, sub); + vec3 col = bilerp(here + stepN * off); + imageStore(uOut, p, vec4(col, src.a)); +} diff --git a/src/vkgs/shaders/gs_common.glsl b/src/vkgs/shaders/gs_common.glsl new file mode 100644 index 0000000000000000000000000000000000000000..2e15342f40c93b197723b80d0b04644025d28a83 --- /dev/null +++ b/src/vkgs/shaders/gs_common.glsl @@ -0,0 +1,32 @@ +// 3DGS 셰이더 공통 정의. 수식과 상수는 gsplat 1.4.0 (학습기)과 똑같이 맞춘다 — +// 다르면 학습기 렌더와 Vulkan 렌더가 픽셀 단위로 어긋난다 (roadmap 2단계 합격 기준 < 2%). + +// 가우시안 하나 = vec4 15개 (240 B). 3D 공분산은 로드 시 CPU 에서 미리 계산한다. +struct Gaussian { + vec4 pos_opacity; // xyz = 씬 원점 기준 위치, w = sigmoid 적용된 불투명도 + vec4 cov_a; // Σ xx, xy, xz, yy + vec4 cov_b; // Σ yz, zz, -, - + vec4 sh[12]; // SH 계수 16개 × RGB = 48 float. sh 계수 k 의 채널 c 는 [k*3+c] +}; + +// 화면공간 스플랫 = vec4 3개 +// [0] = (u, v, radius, opacity) u,v 는 픽셀 좌표 (gsplat 과 같이 픽셀 중심이 +0.5) +// [1] = (conic.xyz, -) 2D 공분산의 역행렬 (a, b, c) +// [2] = (rgb, -) + +#include "bindless.glsl" + +layout(std140, set = 0, binding = 1) uniform Camera { + mat4 w2c; // 씬 원점 기준 world → camera (OpenCV: x 우, y 하, z 전방) + vec4 intr; // fx, fy, cx, cy (픽셀) + vec4 size; // width, height, near, far + vec4 campos; // xyz = 카메라 위치 (씬 원점 기준), w = 높이 클립 (이보다 높은 가우시안은 안 그림) + uvec4 counts; // N, N_pow2, sh_degree, - + mat4 obj_xform; // 객체 에셋(자차 등) 로컬 → 씬 좌표. obj.x 번째 가우시안부터 적용 + uvec4 obj; // x = 객체 시작 인덱스 (없으면 N), y = 보이기(1)/숨기기(0) + uvec4 objs; // 장면 객체(차량 에셋 등, 별도 버퍼): x = 가우시안 SSBO 슬롯, y = 객체 번호 SSBO 슬롯, + // z = 객체 표 SSBO 슬롯, w = 객체 가우시안 수. 전역 번호 counts.x + j 가 객체 가우시안 j + uvec4 mode; // 카메라 모드 (camera_mode.comp): x = 0 RGB · 1 깊이 · 2 시맨틱 · 3 흑백, + // y = 시맨틱 차량 색 (RGBA8, 객체 에셋), z = 시맨틱 하늘 색 (RGBA8), w = 깊이 최대 거리 m (float 비트) +} gCamera[]; +#define cam gCamera[kUboCamera] diff --git a/src/vkgs/shaders/gs_preprocess.comp b/src/vkgs/shaders/gs_preprocess.comp new file mode 100644 index 0000000000000000000000000000000000000000..d5b8ec084b6e0fd3931b4de8ed7bcb11c2c16296 --- /dev/null +++ b/src/vkgs/shaders/gs_preprocess.comp @@ -0,0 +1,145 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +// 가우시안마다: 투영 → 2D 공분산 → conic/반지름 → SH 색 → 정렬 키. +// gsplat fully_fused_projection_fwd.cu / spherical_harmonics.cuh 를 그대로 옮겼다. + +layout(local_size_x = 256) in; + +#include "gs_common.glsl" + +layout(std430, set = 0, binding = 0) readonly buffer Gaussians { Gaussian g[]; } bGauss[]; +layout(std430, set = 0, binding = 0) writeonly buffer Splats { vec4 splat[]; } bSplats[]; +layout(std430, set = 0, binding = 0) writeonly buffer Keys { uvec2 keys[]; } bKeys[]; +layout(std430, set = 0, binding = 0) buffer DrawArgs { + uint vertexCount; uint instanceCount; uint firstVertex; uint firstInstance; +} bDraw[]; +#define g bGauss[kSsboGauss].g +// 장면 객체 (차량 에셋): 객체 가우시안 j 의 객체 번호, 객체 표 (박스 로컬 → 씬, 보이기) +layout(std430, set = 0, binding = 0) readonly buffer ObjGaussians { Gaussian og[]; } bObjGauss[]; +layout(std430, set = 0, binding = 0) readonly buffer ObjIds { uint id[]; } bObjIds[]; +struct ObjInstance { mat4 xform; vec4 flags; }; // flags.x = 보이기 +layout(std430, set = 0, binding = 0) readonly buffer ObjTable { ObjInstance inst[]; } bObjTable[]; +#define splat bSplats[kSsboSplats].splat +#define keys bKeys[kSsboKeys].keys +#define draw bDraw[kSsboDraw] + +const float EPS2D = 0.3; // gsplat rasterization(eps2d=0.3) — 저역 통과 블러 + +vec3 sh_color(in Gaussian G, vec3 dir, uint degree) { + // sh_coeffs_to_color_fast 와 같은 순서/상수 + vec3 c[16]; + for (int k = 0; k < 16; ++k) { + int f = k * 3; + c[k] = vec3(G.sh[f / 4][f % 4], G.sh[(f + 1) / 4][(f + 1) % 4], G.sh[(f + 2) / 4][(f + 2) % 4]); + } + vec3 r = 0.2820947917738781 * c[0]; + if (degree < 1) return r; + vec3 d = normalize(dir); + float x = d.x, y = d.y, z = d.z; + r += 0.48860251190292 * (-y * c[1] + z * c[2] - x * c[3]); + if (degree < 2) return r; + float z2 = z * z; + float fTmp0B = -1.092548430592079 * z; + float fC1 = x * x - y * y; + float fS1 = 2.0 * x * y; + r += (0.5462742152960395 * fS1) * c[4] + (fTmp0B * y) * c[5] + + (0.9461746957575601 * z2 - 0.3153915652525201) * c[6] + + (fTmp0B * x) * c[7] + (0.5462742152960395 * fC1) * c[8]; + if (degree < 3) return r; + float fTmp0C = -2.285228997322329 * z2 + 0.4570457994644658; + float fTmp1B = 1.445305721320277 * z; + float fC2 = x * fC1 - y * fS1; + float fS2 = x * fS1 + y * fC1; + r += (-0.5900435899266435 * fS2) * c[9] + (fTmp1B * fS1) * c[10] + + (fTmp0C * y) * c[11] + (z * (1.865881662950577 * z2 - 1.119528997770346)) * c[12] + + (fTmp0C * x) * c[13] + (fTmp1B * fC1) * c[14] + (-0.5900435899266435 * fC2) * c[15]; + return r; +} + +void main() { + uint i = gl_GlobalInvocationID.x; + uint n = cam.counts.x; + if (i >= n + cam.objs.w) return; + // 전역 번호 i: [0, n) 배경 (+ 자차 에셋), [n, n + objs.w) 장면 객체 (별도 버퍼, 객체마다 자세) + Gaussian G; + mat4 X = cam.obj_xform; + bool is_obj = false; + if (i >= n) { + uint j = i - n; + G = bObjGauss[cam.objs.x].og[j]; + ObjInstance oi = bObjTable[cam.objs.z].inst[bObjIds[cam.objs.y].id[j]]; + if (oi.flags.x < 0.5) return; // 지웠거나 이 시각에 없는 객체 + X = oi.xform; + is_obj = true; + } else { + G = g[i]; + // 객체 에셋(자차): 박스 로컬 좌표 → 이번 프레임의 자차 포즈 + is_obj = i >= cam.obj.x; + if (is_obj && cam.obj.y == 0u) return; + } + vec3 p = G.pos_opacity.xyz; + if (is_obj) p = (X * vec4(p, 1.0)).xyz; + // BEV 에서 나무 캐노피가 지면을 덮지 않도록 일정 높이 위는 뺀다 + if (p.z > cam.campos.w) return; + vec3 t = (cam.w2c * vec4(p, 1.0)).xyz; + float near = cam.size.z, far = cam.size.w; + if (t.z < near || t.z > far) return; + + // world 공분산 → camera 공분산 + vec4 a = G.cov_a, b = G.cov_b; + mat3 S = mat3(a.x, a.y, a.z, + a.y, a.w, b.x, + a.z, b.x, b.y); + if (is_obj) { + mat3 Ro = mat3(X); + S = Ro * S * transpose(Ro); + } + mat3 W = mat3(cam.w2c); + mat3 Sc = W * S * transpose(W); + + // persp_proj: 화면 밖 멀리 있는 가우시안의 야코비안이 폭주하지 않게 x/z 를 자른다 + float fx = cam.intr.x, fy = cam.intr.y, cx = cam.intr.z, cy = cam.intr.w; + float w = cam.size.x, h = cam.size.y; + float tan_fovx = 0.5 * w / fx, tan_fovy = 0.5 * h / fy; + float lim_x_pos = (w - cx) / fx + 0.3 * tan_fovx; + float lim_x_neg = cx / fx + 0.3 * tan_fovx; + float lim_y_pos = (h - cy) / fy + 0.3 * tan_fovy; + float lim_y_neg = cy / fy + 0.3 * tan_fovy; + float rz = 1.0 / t.z, rz2 = rz * rz; + float tx = t.z * min(lim_x_pos, max(-lim_x_neg, t.x * rz)); + float ty = t.z * min(lim_y_pos, max(-lim_y_neg, t.y * rz)); + vec3 j0 = vec3(fx * rz, 0.0, -fx * tx * rz2); + vec3 j1 = vec3(0.0, fy * rz, -fy * ty * rz2); + float c00 = dot(j0, Sc * j0) + EPS2D; + float c01 = dot(j0, Sc * j1); + float c11 = dot(j1, Sc * j1) + EPS2D; + + float det = c00 * c11 - c01 * c01; + if (det <= 0.0) return; + vec3 conic = vec3(c11, -c01, c00) / det; + + float bb = 0.5 * (c00 + c11); + float v1 = bb + sqrt(max(0.01, bb * bb - det)); + float radius = ceil(3.0 * sqrt(v1)); + vec2 mean2d = vec2(fx * t.x * rz + cx, fy * t.y * rz + cy); + if (mean2d.x + radius <= 0.0 || mean2d.x - radius >= w || + mean2d.y + radius <= 0.0 || mean2d.y - radius >= h) return; + + vec3 rgb; + if (cam.mode.x == 2u) { // 시맨틱 카메라: 라벨 색 (ply.cpp 가 semantic.rgba 를 cov_b.w 에 넣는다). 객체 에셋 = 차량 색 + rgb = is_obj ? unpackUnorm4x8(cam.mode.y).rgb : unpackUnorm4x8(floatBitsToUint(G.cov_b.w)).rgb; + } else { + rgb = sh_color(G, p - cam.campos.xyz, cam.counts.z); + rgb = max(rgb + 0.5, vec3(0.0)); // gsplat: clamp_min(colors + 0.5, 0) + } + + splat[3 * i + 0] = vec4(mean2d, radius, G.pos_opacity.w); + splat[3 * i + 1] = vec4(conic, 0.0); + splat[3 * i + 2] = vec4(rgb, t.z); // w = 카메라 깊이 — 메시(PBR)와 깊이 합성용 + + // 뒤에서 앞으로 그리므로 먼 것이 먼저 오도록 깊이를 뒤집는다. + // 양수 float 의 비트 패턴은 값과 단조 관계라 uint 비교로 정렬된다. + // 보이는 것만 앞쪽에 모은다 (정렬은 [0, 보이는 수) 만 한다 — gs_radix_*.comp, bitonic 은 뒤를 채워서) + uint slot = atomicAdd(draw.instanceCount, 1u); + keys[slot] = uvec2(0xFFFFFFFEu - floatBitsToUint(t.z), i); +} diff --git a/src/vkgs/shaders/gs_radix_common.glsl b/src/vkgs/shaders/gs_radix_common.glsl new file mode 100644 index 0000000000000000000000000000000000000000..aba14d9113a4674cbe040d13920f1c11009e8bf2 --- /dev/null +++ b/src/vkgs/shaders/gs_radix_common.glsl @@ -0,0 +1,19 @@ +// GPU radix 정렬 (LSD, 8비트 자릿수 × 4패스) 공통 — gs_radix_hist / scan / scatter.comp. +// 정렬 대상: keys[0, 보이는 수) 의 (key, index). 보이는 수는 GPU 의 인다이렉트 인자에서 읽는다 (CPU 왕복 없음). +// 워크그룹 수 G 는 고정이고, 워크그룹 g 는 연속 블록 [g·B, (g+1)·B) 를 맡는다 (B = 보이는 수 / G 를 256 배수로 올림). +// 패스마다 원본 ↔ 임시 버퍼를 오가므로 4패스 뒤 결과는 다시 keys 에 있다. 안정 정렬이다. +#include "bindless.glsl" +layout(std430, set = 0, binding = 0) buffer Keys { uvec2 keys[]; } bKeys[]; +layout(std430, set = 0, binding = 0) buffer Hist { uint h[]; } bHist[]; +layout(std430, set = 0, binding = 0) readonly buffer DrawArgs { + uint vertexCount; uint instanceCount; uint firstVertex; uint firstInstance; +} bDraw[]; +// shift = 이번 자릿수 위치, flip = 0 이면 keys → tmp, 1 이면 tmp → keys, groups = G, slots = (keys, tmp, hist) +layout(push_constant) uniform Radix { uint shift; uint flip; uint groups; uint pad; uvec4 slots; } pc; +#define SRC bKeys[pc.flip == 0u ? pc.slots.x : pc.slots.y].keys +#define DST bKeys[pc.flip == 0u ? pc.slots.y : pc.slots.x].keys +#define HIST bHist[pc.slots.z].h +const uint RADIX = 256u; + +uint radix_count() { return bDraw[kSsboDraw].instanceCount; } +uint radix_block(uint count) { return ((count + pc.groups - 1u) / pc.groups + 255u) / 256u * 256u; } diff --git a/src/vkgs/shaders/gs_radix_hist.comp b/src/vkgs/shaders/gs_radix_hist.comp new file mode 100644 index 0000000000000000000000000000000000000000..2c5fab4939515bf5f5994bdfe19dddf2b13a27eb --- /dev/null +++ b/src/vkgs/shaders/gs_radix_hist.comp @@ -0,0 +1,19 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +#extension GL_EXT_nonuniform_qualifier : require +// radix 1/3: 워크그룹마다 자기 블록의 자릿수 히스토그램 → HIST[g·256 + d] +layout(local_size_x = 256) in; +#include "gs_radix_common.glsl" + +shared uint s_hist[RADIX]; + +void main() { + uint t = gl_LocalInvocationID.x, g = gl_WorkGroupID.x; + s_hist[t] = 0u; + barrier(); + uint count = radix_count(), block = radix_block(count); + uint b0 = min(g * block, count), b1 = min(b0 + block, count); + for (uint i = b0 + t; i < b1; i += 256u) atomicAdd(s_hist[(SRC[i].x >> pc.shift) & 255u], 1u); + barrier(); + HIST[g * RADIX + t] = s_hist[t]; +} diff --git a/src/vkgs/shaders/gs_radix_scan.comp b/src/vkgs/shaders/gs_radix_scan.comp new file mode 100644 index 0000000000000000000000000000000000000000..a4e14a87d850bca52fdc83e199507a71cb0e583a --- /dev/null +++ b/src/vkgs/shaders/gs_radix_scan.comp @@ -0,0 +1,30 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +#extension GL_EXT_nonuniform_qualifier : require +// radix 2/3 (워크그룹 하나): HIST[g·256 + d] → 그 (자릿수, 워크그룹) 의 출력 시작 위치. +// 순서는 자릿수 먼저, 같은 자릿수 안에서 워크그룹 순 — 그래서 안정 정렬이 된다. +layout(local_size_x = 256) in; +#include "gs_radix_common.glsl" + +shared uint s_sum[RADIX]; + +void main() { + uint d = gl_LocalInvocationID.x; + uint total = 0u; + for (uint g = 0u; g < pc.groups; ++g) total += HIST[g * RADIX + d]; + s_sum[d] = total; + barrier(); + // 자릿수 합의 배타적 누적 (Hillis–Steele) + for (uint off = 1u; off < RADIX; off <<= 1u) { + uint v = d >= off ? s_sum[d - off] : 0u; + barrier(); + s_sum[d] += v; + barrier(); + } + uint run = s_sum[d] - total; + for (uint g = 0u; g < pc.groups; ++g) { + uint c = HIST[g * RADIX + d]; + HIST[g * RADIX + d] = run; + run += c; + } +} diff --git a/src/vkgs/shaders/gs_radix_scatter.comp b/src/vkgs/shaders/gs_radix_scatter.comp new file mode 100644 index 0000000000000000000000000000000000000000..446032150dea2873818ed544ee731a106e91d8ff --- /dev/null +++ b/src/vkgs/shaders/gs_radix_scatter.comp @@ -0,0 +1,53 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +#extension GL_EXT_nonuniform_qualifier : require +#extension GL_KHR_shader_subgroup_basic : require +#extension GL_KHR_shader_subgroup_ballot : require +// radix 3/3: 블록을 256개씩 차례로, 같은 자릿수끼리의 순위(안정)를 매겨 제자리로 보낸다. +// 순위: subgroup ballot 8번으로 '같은 자릿수인 레인' 마스크를 만들고 (WLMS), 그 안에서 앞선 레인 수. +// subgroup 사이는 공유 메모리의 (subgroup, 자릿수) 개수 누적으로 잇는다. subgroup 크기 16 이상이 필요하다 (뷰어가 검사). +layout(local_size_x = 256) in; +#include "gs_radix_common.glsl" + +const uint MAX_SG = 16u; // 256 / 16 +shared uint s_off[RADIX]; // 이 워크그룹의 자릿수별 다음 출력 위치 +shared uint s_sg[MAX_SG][RADIX]; // (subgroup, 자릿수) 개수 → 배타적 누적 + +void main() { + uint t = gl_LocalInvocationID.x, g = gl_WorkGroupID.x; + uint nsg = gl_NumSubgroups, sg = gl_SubgroupID; + s_off[t] = HIST[g * RADIX + t]; + uint count = radix_count(), block = radix_block(count); + uint b0 = min(g * block, count), b1 = min(b0 + block, count); + // 원소 순서 = (subgroup, 레인) 순서 — 안정성을 위해 로드 위치를 그 순서로 정한다 + uint lane_index = sg * gl_SubgroupSize + gl_SubgroupInvocationID; + for (uint c = b0; c < b1; c += 256u) { + for (uint s = 0u; s < nsg; ++s) s_sg[s][t] = 0u; + barrier(); + uint i = c + lane_index; + bool valid = i < b1; + uvec2 kv = valid ? SRC[i] : uvec2(0u); + uint d = (kv.x >> pc.shift) & 255u; + uvec4 peers = subgroupBallot(valid); + for (uint b = 0u; b < 8u; ++b) { + bool bit = ((d >> b) & 1u) != 0u; + uvec4 bal = subgroupBallot(bit); + peers &= bit ? bal : ~bal; + } + uint rank = subgroupBallotExclusiveBitCount(peers); + if (valid && subgroupBallotFindLSB(peers) == gl_SubgroupInvocationID) + s_sg[sg][d] = subgroupBallotBitCount(peers); + barrier(); + uint run = 0u; // 스레드 t = 자릿수 t + for (uint s = 0u; s < nsg; ++s) { + uint v = s_sg[s][t]; + s_sg[s][t] = run; + run += v; + } + barrier(); + if (valid) DST[s_off[d] + s_sg[sg][d] + rank] = kv; + barrier(); + s_off[t] += run; + barrier(); + } +} diff --git a/src/vkgs/shaders/gs_sort.comp b/src/vkgs/shaders/gs_sort.comp new file mode 100644 index 0000000000000000000000000000000000000000..5404292d74654679f0d8ccf39e4b925082b24967 --- /dev/null +++ b/src/vkgs/shaders/gs_sort.comp @@ -0,0 +1,32 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +// bitonic 정렬 한 단계 (k, j). (key, index) 쌍을 key 오름차순으로 — 기준 구현 (기본은 radix, gs_radix_*.comp). +// 전처리가 보이는 것만 [0, 보이는 수) 에 모으므로, k = 0 인 '채우기' 단계가 먼저 뒤쪽을 안 보임 키로 채운다. +// N=2^21 이면 231 번 디스패치. + +layout(local_size_x = 256) in; + +#include "bindless.glsl" +layout(std430, set = 0, binding = 0) buffer Keys { uvec2 keys[]; } bKeys[]; +layout(std430, set = 0, binding = 0) readonly buffer DrawArgs { + uint vertexCount; uint instanceCount; uint firstVertex; uint firstInstance; +} bDraw[]; +#define keys bKeys[kSsboKeys].keys +layout(push_constant) uniform Step { uint j; uint k; uint n; } pc; + +void main() { + uint i = gl_GlobalInvocationID.x; + if (i >= pc.n) return; + if (pc.k == 0u) { // 채우기: 보이지 않는 자리 → 정렬 후 맨 뒤 + if (i >= bDraw[kSsboDraw].instanceCount) keys[i] = uvec2(0xFFFFFFFFu, 0u); + return; + } + uint l = i ^ pc.j; + if (l <= i) return; + uvec2 a = keys[i], b = keys[l]; + bool ascending = (i & pc.k) == 0u; + if (ascending ? (a.x > b.x) : (a.x < b.x)) { + keys[i] = b; + keys[l] = a; + } +} diff --git a/src/vkgs/shaders/gs_splat.frag b/src/vkgs/shaders/gs_splat.frag new file mode 100644 index 0000000000000000000000000000000000000000..dfaac0e42063f5d7815bdddc4d4782043f270486 --- /dev/null +++ b/src/vkgs/shaders/gs_splat.frag @@ -0,0 +1,21 @@ +#version 450 +// gsplat rasterize_to_pixels_fwd.cu 의 픽셀 기여 계산과 같다. +// 뒤→앞 순서로 premultiplied over 블렌딩 (src ONE, dst ONE_MINUS_SRC_ALPHA). +// 앞→뒤로 합치는 gsplat 과 수학적으로 같고, 다른 점은 gsplat 의 조기 종료(T ≤ 1e-4) 뿐이다. + +layout(location = 0) flat in vec2 vCenter; +layout(location = 1) flat in vec3 vConic; +layout(location = 2) flat in vec4 vColor; +layout(location = 3) flat in float vZ; +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outDepth; // 거리 누적 (depth_common.glsl) + +void main() { + vec2 d = vCenter - gl_FragCoord.xy; // gl_FragCoord 는 픽셀 중심(+0.5) — gsplat 의 px 와 같다 + float sigma = 0.5 * (vConic.x * d.x * d.x + vConic.z * d.y * d.y) + vConic.y * d.x * d.y; + if (sigma < 0.0) discard; + float alpha = min(0.999, vColor.a * exp(-sigma)); + if (alpha < 1.0 / 255.0) discard; + outColor = vec4(vColor.rgb * alpha, alpha); + outDepth = vec4(vZ * alpha, alpha, 0.0, alpha); +} diff --git a/src/vkgs/shaders/gs_splat.vert b/src/vkgs/shaders/gs_splat.vert new file mode 100644 index 0000000000000000000000000000000000000000..863df62fe2339a448ec8c1607dec0685272e12c0 --- /dev/null +++ b/src/vkgs/shaders/gs_splat.vert @@ -0,0 +1,33 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +#include "bindless.glsl" +// 정렬된 순서대로 스플랫마다 화면정렬 쿼드 하나 (3σ 반지름). 인스턴스 = 가우시안. + +layout(std430, set = 0, binding = 0) readonly buffer Splats { vec4 splat[]; } bSplats[]; +layout(std430, set = 0, binding = 0) readonly buffer Keys { uvec2 keys[]; } bKeys[]; +#define splat bSplats[kSsboSplats].splat +#define keys bKeys[kSsboKeys].keys +layout(push_constant) uniform Viewport { vec2 size; vec2 near_far; } pc; + +layout(location = 0) flat out vec2 vCenter; +layout(location = 1) flat out vec3 vConic; +layout(location = 2) flat out vec4 vColor; // rgb, opacity +layout(location = 3) flat out float vZ; // 가우시안 중심의 카메라 z (거리 누적 버퍼용) + +void main() { + uint idx = keys[gl_InstanceIndex].y; + vec4 s0 = splat[3 * idx + 0]; + vCenter = s0.xy; + vConic = splat[3 * idx + 1].xyz; + vColor = vec4(splat[3 * idx + 2].rgb, s0.w); + + vec2 corner = vec2(float(gl_VertexIndex & 1) * 2.0 - 1.0, float(gl_VertexIndex >> 1) * 2.0 - 1.0); + vec2 pix = s0.xy + corner * s0.z; + // 픽셀 → Vulkan NDC. y 가 아래로 + 라 뒤집지 않는다 (nusim.data.rig 참고). + // 깊이 = 가우시안 중심 깊이를 Vulkan [0,1] 로 (nusim.data.rig projection 과 같은 식). + // 메시(PBR 구, glTF)가 먼저 깊이를 쓰면 그 뒤의 스플랫이 깊이 테스트로 가려진다. + float n = pc.near_far.x, f = pc.near_far.y, z = splat[3 * idx + 2].w; + vZ = z; + float zndc = f / (f - n) - f * n / ((f - n) * z); + gl_Position = vec4(pix / pc.size * 2.0 - 1.0, zndc, 1.0); +} diff --git a/src/vkgs/shaders/ibl_common.glsl b/src/vkgs/shaders/ibl_common.glsl new file mode 100644 index 0000000000000000000000000000000000000000..b51711c938e5cae3f548b97fa52fedd745a9fe97 --- /dev/null +++ b/src/vkgs/shaders/ibl_common.glsl @@ -0,0 +1,36 @@ +// IBL 공통 — nusim/render/ibl.py 와 짝. +// 큐브맵은 씬 좌표(z 위) 방향 벡터로 바로 샘플링한다 (굽는 쪽이 Vulkan 면 표를 그대로 썼다). +// 색 공간: 큐브맵은 선형 HDR(근사), 3DGS 는 카메라 sRGB(정준 색). 화면은 3DGS 쪽에 맞춘다. + +#include "bindless.glsl" +#define uSpecular gCube[gGlobals[kSsboGlobals].ibl.x] // GGX 프리필터, mip = 거칠기 × (mips-1) +#define uIrradiance gCube[gGlobals[kSsboGlobals].ibl.y] // 복사조도 / π +#define uBrdfLut gTex2D[gGlobals[kSsboGlobals].ibl.z] // (NdotV, 거칠기) → (scale, bias) + +vec3 linear_to_srgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c)); +} + +// 굽기 때의 하이라이트 확장(nusim/render/ibl.py expand_highlights: knee 0.75, gain 6)을 되돌려 카메라가 찍었을 +// 밝기로 — 배경 표시용. 정방향 L = l·(1 + 6x²), x = clamp((l − 0.75)/0.25, 0, 1) 은 l 에 대해 단조 증가라 +// 구간 [0.75, 1] 에서 이분법으로 정확히 푼다 (l ≥ 1 이면 L = 7l). +// 예전 고정점 반복 l ← L/(1+6x(l)²) 은 L 과 L/7 사이를 오가며 발산했고, 짝수 번(4회) 돌아 입력을 그대로 +// 돌려줬다 — 원래 휘도 0.8~1 인 하늘·반사가 최대 7배 밝게 하얗게 날아갔다 (2026-10-02 리뷰). +vec3 compress_highlights(vec3 lin) { + float L = dot(lin, vec3(0.2126, 0.7152, 0.0722)); + if (L <= 0.75) return lin; + float Li; + if (L >= 7.0) { + Li = L / 7.0; + } else { + float lo = 0.75, hi = 1.0; + for (int k = 0; k < 14; ++k) { + float m = 0.5 * (lo + hi); + float x = (m - 0.75) / 0.25; + if (m * (1.0 + 6.0 * x * x) < L) lo = m; else hi = m; + } + Li = 0.5 * (lo + hi); + } + return lin * (Li / L); +} diff --git a/src/vkgs/shaders/line.frag b/src/vkgs/shaders/line.frag new file mode 100644 index 0000000000000000000000000000000000000000..26859e41207d74fdf9e9919daacfc3e65366ec1b --- /dev/null +++ b/src/vkgs/shaders/line.frag @@ -0,0 +1,7 @@ +#version 450 +layout(location = 0) in vec4 vColor; +layout(location = 0) out vec4 outColor; + +void main() { + outColor = vec4(vColor.rgb * vColor.a, vColor.a); // premultiplied +} diff --git a/src/vkgs/shaders/line.vert b/src/vkgs/shaders/line.vert new file mode 100644 index 0000000000000000000000000000000000000000..49101f8112dbe824bc5305733723401e4f11d171 --- /dev/null +++ b/src/vkgs/shaders/line.vert @@ -0,0 +1,17 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +// 맵 오버레이(차선, 주행가능영역 경계, 궤적). 3DGS 위에 겹쳐 그린다 (깊이 테스트 없음). +// 투영은 nusim.data.rig.CameraPose.projection 과 같은 Vulkan clip — 근평면 클리핑은 하드웨어가 한다. + +#include "bindless.glsl" +// 정점은 bindless SSBO 에서 직접 읽는다 (vertex pulling): LineVertex = 위치 xyz(씬 원점 기준) + 색 rgba = float 7개 +layout(std430, set = 0, binding = 0) readonly buffer LineVerts { float lv[]; } gLineVerts[]; +layout(push_constant) uniform PC { mat4 viewProj; ivec4 idx; } pc; // idx.x = 정점 SSBO 슬롯 +layout(location = 0) out vec4 vColor; + +void main() { + uint b = uint(gl_VertexIndex) * 7u, s = uint(pc.idx.x); + vec3 inPos = vec3(gLineVerts[s].lv[b], gLineVerts[s].lv[b + 1u], gLineVerts[s].lv[b + 2u]); + vColor = vec4(gLineVerts[s].lv[b + 3u], gLineVerts[s].lv[b + 4u], gLineVerts[s].lv[b + 5u], gLineVerts[s].lv[b + 6u]); + gl_Position = pc.viewProj * vec4(inPos, 1.0); +} diff --git a/src/vkgs/shaders/nodes/isp.glsl b/src/vkgs/shaders/nodes/isp.glsl new file mode 100644 index 0000000000000000000000000000000000000000..142ddaead532f22bd0b621b242dfb6f271863ba1 --- /dev/null +++ b/src/vkgs/shaders/nodes/isp.glsl @@ -0,0 +1,82 @@ +// 셰이더 그래프 노드 라이브러리 — 카메라 ISP (scripts/shader_graph.py 가 읽는다). +// +// 함수 하나 = 노드 하나. 이름이 n_ 인 함수만 노드다. 시그니처가 곧 노드 정의다: +// 반환형 n_(입력..., Ctx x, 매개변수...) +// - Ctx 앞의 인자 = 입력 포트 (다른 노드의 출력과 타입이 맞아야 한다) +// - Ctx 뒤의 float 인자 = 매개변수 (실행 중 슬라이더로 바꾼다 — 매개변수 버퍼에서 읽는다) +// - Ctx 가 없으면 입력만 받는 순수 함수 +// 매개변수의 기본값·범위는 바로 위 주석 '// @param 이름 기본값 최솟값 최댓값' 으로 준다 (없으면 0, 0~1). +// 노드는 픽셀 하나만 본다 (이웃 픽셀을 읽지 않는다) — 그래서 결과를 같은 이미지에 덮어써도 안전하다. + +struct Ctx { + ivec2 px; // 픽셀 + vec2 uv; // 0~1 + vec2 size; // 폭, 높이 + float time; // 시뮬레이션 시각 s + uint frame; // 프레임 번호 (노이즈 씨앗) +}; + +// sRGB 부호화 ↔ 선형. 렌더 결과(post)는 sRGB 로 부호화돼 있다 (학습 이미지가 JPEG). +vec3 n_to_linear(vec3 c) { return mix(c / 12.92, pow((max(c, 0.0) + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c)); } +vec3 n_to_srgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c)); +} + +// 노출 (EV, 선형 색에) +// @param ev 0 -3 3 +vec3 n_exposure(vec3 c, Ctx x, float ev) { return c * exp2(ev); } + +// 화이트밸런스 — 색온도(+ 따뜻하게 / - 차갑게)·틴트(+ 마젠타 / - 초록) 이득 (선형 색에) +// @param temperature 0 -1 1 +// @param tint 0 -1 1 +vec3 n_white_balance(vec3 c, Ctx x, float temperature, float tint) { + vec3 g = vec3(1.0 + 0.3 * temperature, 1.0 - 0.2 * tint, 1.0 - 0.3 * temperature); + return c * g / dot(g, vec3(0.2126, 0.7152, 0.0722)); // 밝기는 유지 +} + +// 채도 (1 = 그대로, 0 = 흑백) +// @param saturation 1 0 2 +vec3 n_saturation(vec3 c, Ctx x, float saturation) { + float y = dot(c, vec3(0.2126, 0.7152, 0.0722)); + return max(mix(vec3(y), c, saturation), 0.0); +} + +// 대비 (중간 회색 기준, sRGB 부호화 색에) +// @param contrast 1 0.5 1.5 +vec3 n_contrast(vec3 c, Ctx x, float contrast) { return max((c - 0.5) * contrast + 0.5, 0.0); } + +// 비네팅 — 렌즈 주변 감광 cos^4 (선형 색에). 시야각은 화면 대각 기준 근사 +// @param vignette 0 0 1 +vec3 n_vignette(vec3 c, Ctx x, float vignette) { + vec2 d = (x.uv - 0.5) * vec2(x.size.x / x.size.y, 1.0); + float cos_t = inversesqrt(1.0 + dot(d, d) * 1.2); + return c * mix(1.0, pow(cos_t, 4.0), vignette); +} + +// 센서 노이즈 — 샷 노이즈(밝기에 비례) + 읽기 노이즈 (선형 색에). 프레임마다 다르다 +// @param shot_noise 0 0 0.05 +// @param read_noise 0 0 0.02 +vec3 n_sensor_noise(vec3 c, Ctx x, float shot_noise, float read_noise) { + uvec3 s = uvec3(uvec2(x.px), x.frame); + // PCG 해시 3개 → 근사 정규분포 (균등 둘의 합) + vec3 n = vec3(0.0); + for (int k = 0; k < 2; ++k) { + s = s * 1664525u + 1013904223u; + s.x += s.y * s.z; s.y += s.z * s.x; s.z += s.x * s.y; + s ^= s >> 16u; + s.x += s.y * s.z; s.y += s.z * s.x; s.z += s.x * s.y; + n += vec3(s) * (1.0 / 4294967296.0) - 0.5; + } + n *= 2.449; // 분산 1 로 + return max(c + n * (shot_noise * sqrt(max(c, 0.0)) + read_noise), 0.0); +} + +// 두 갈래 섞기 +// @param amount 0.5 0 1 +vec3 n_mix(vec3 a, vec3 b, Ctx x, float amount) { return mix(a, b, amount); } + +// 밝기 (float 출력 — 타입 검사 예시·마스크용) +float n_luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); } +// 회색으로 (float → vec3) +vec3 n_gray(float y) { return vec3(y); } diff --git a/src/vkgs/shaders/pbr.frag b/src/vkgs/shaders/pbr.frag new file mode 100644 index 0000000000000000000000000000000000000000..aa86bf05dc22ef0ee07f52f62d2dfec0e2769148 --- /dev/null +++ b/src/vkgs/shaders/pbr.frag @@ -0,0 +1,45 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +// split-sum IBL (Karis 2013): 확산 = 복사조도·알베도, 정반사 = 프리필터(R, 거칠기)·(F0·A + B) +#include "ibl_common.glsl" +#include "depth_common.glsl" +layout(location = 0) in vec3 vWorld; +layout(location = 1) in vec3 vNormal; +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outDepth; // 거리 누적 (depth_common.glsl) +layout(push_constant) uniform PC { + mat4 viewProj; + vec4 center_radius; + vec4 albedo_rough; + vec4 metal; + vec4 campos; + ivec4 idx; +} pc; + +vec3 fresnel_schlick_roughness(float cosT, vec3 F0, float r) { + return F0 + (max(vec3(1.0 - r), F0) - F0) * pow(1.0 - cosT, 5.0); +} + +void main() { + vec3 N = normalize(vNormal); + vec3 V = normalize(pc.campos.xyz - vWorld); + vec3 R = reflect(-V, N); + float NdotV = max(dot(N, V), 1e-4); + vec3 albedo = pc.albedo_rough.rgb; + float rough = clamp(pc.albedo_rough.a, 0.0, 1.0); + float metallic = pc.metal.x; + vec3 F0 = mix(vec3(0.04), albedo, metallic); + + float mips = float(textureQueryLevels(uSpecular)); + vec3 pref = textureLod(uSpecular, R, rough * (mips - 1.0)).rgb; + vec2 ab = texture(uBrdfLut, vec2(NdotV, rough)).rg; + vec3 spec = pref * (F0 * ab.x + ab.y); + + vec3 kS = fresnel_schlick_roughness(NdotV, F0, rough); + vec3 kD = (1.0 - kS) * (1.0 - metallic); + vec3 diffuse = texture(uIrradiance, N).rgb * albedo; + vec3 lin = kD * diffuse + spec; + // 3DGS 와 같은 카메라 sRGB 공간으로. 하이라이트 확장도 되돌려 장면 밝기와 맞춘다. + outColor = vec4(linear_to_srgb(compress_highlights(lin)), 1.0); + outDepth = vec4(linear_depth(gl_FragCoord.z), 1.0, 0.0, 1.0); +} diff --git a/src/vkgs/shaders/pbr.vert b/src/vkgs/shaders/pbr.vert new file mode 100644 index 0000000000000000000000000000000000000000..a5f0d990d17ab679ecc348a0d5e723b7442e7567 --- /dev/null +++ b/src/vkgs/shaders/pbr.vert @@ -0,0 +1,25 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +#include "bindless.glsl" +// IBL 검증용 PBR 메시 (구). glTF 차량도 같은 셰이더 경로로 들어올 것이다. +// 정점은 bindless SSBO 에서 (vertex pulling): 위치 xyz + 법선 xyz = float 6개 +layout(std430, set = 0, binding = 0) readonly buffer SphereVerts { float sv[]; } gSphereVerts[]; +layout(push_constant) uniform PC { + mat4 viewProj; + vec4 center_radius; // 구 중심(씬 좌표), 반지름 + vec4 albedo_rough; // rgb 알베도(선형), 거칠기 + vec4 metal; // x 금속성 + vec4 campos; // 카메라 위치 + ivec4 idx; // x = 정점 SSBO 슬롯 +} pc; +layout(location = 0) out vec3 vWorld; +layout(location = 1) out vec3 vNormal; +void main() { + uint b = uint(gl_VertexIndex) * 6u, s = uint(pc.idx.x); + vec3 inPos = vec3(gSphereVerts[s].sv[b], gSphereVerts[s].sv[b + 1u], gSphereVerts[s].sv[b + 2u]); + vec3 inNormal = vec3(gSphereVerts[s].sv[b + 3u], gSphereVerts[s].sv[b + 4u], gSphereVerts[s].sv[b + 5u]); + vec3 w = pc.center_radius.xyz + inPos * pc.center_radius.w; + vWorld = w; + vNormal = inNormal; + gl_Position = pc.viewProj * vec4(w, 1.0); +} diff --git a/src/vkgs/shaders/pbr_gltf.frag b/src/vkgs/shaders/pbr_gltf.frag new file mode 100644 index 0000000000000000000000000000000000000000..743636566984ecdc531309ed141920071748c132 --- /dev/null +++ b/src/vkgs/shaders/pbr_gltf.frag @@ -0,0 +1,128 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +// glTF metallic-roughness + clearcoat + transmission(근사), split-sum IBL. +#include "ibl_common.glsl" +#include "depth_common.glsl" + +layout(location = 0) in vec3 vWorld; +layout(location = 1) in vec3 vNormal; +layout(location = 2) in vec2 vUV0; +layout(location = 3) in vec2 vUV1; +layout(location = 4) in vec4 vTangent; +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outDepth; // 거리 누적 (depth_common.glsl) + +layout(push_constant) uniform PC { + mat4 model; + mat4 viewProj; + vec4 campos; + ivec4 idx; // x = 재질 SSBO 슬롯, y = 재질 번호 +} pc; + +struct Material { // std430 — viewer.cpp add_gltf 의 MatGPU 와 같은 순서 (304 B) + vec4 base; // 기본색 계수 (선형) + vec4 emissive; // rgb × emissive_strength + vec4 mrno; // metallic, roughness, normal_scale, occlusion_strength + vec4 alpha; // cutoff, mode(0 불투명 1 마스크 2 블렌드), transmission, has_normal_tex + vec4 cc; // clearcoat, clearcoat_roughness, -, - + ivec4 tc0; // texcoord 번호: base, metal-rough, normal, occlusion + ivec4 tc1; // emissive, -, -, - + vec4 xf[10]; // KHR_texture_transform: 슬롯마다 2행 (a b c), (d e f) + ivec4 tex0; // 2D 텍스처 번호: base, metal-rough, normal, occlusion (없으면 흰색) + ivec4 tex1; // emissive, -, -, - +}; +layout(std430, set = 0, binding = 0) readonly buffer Materials { Material m[]; } gMaterials[]; +Material mat; +#define tBase gTex2D[mat.tex0.x] +#define tMetalRough gTex2D[mat.tex0.y] +#define tNormal gTex2D[mat.tex0.z] +#define tOcclusion gTex2D[mat.tex0.w] +#define tEmissive gTex2D[mat.tex1.x] + +vec2 uv_of(int slot) { + int tc = slot < 4 ? mat.tc0[slot] : mat.tc1.x; + vec3 uv = vec3(tc == 0 ? vUV0 : vUV1, 1.0); + return vec2(dot(mat.xf[2 * slot].xyz, uv), dot(mat.xf[2 * slot + 1].xyz, uv)); +} + +vec3 fresnel_schlick_roughness(float cosT, vec3 F0, float r) { + return F0 + (max(vec3(1.0 - r), F0) - F0) * pow(1.0 - cosT, 5.0); +} + +vec3 ibl_specular(vec3 R, float rough, vec3 F0, float NdotV) { + float mips = float(textureQueryLevels(uSpecular)); + vec3 pref = textureLod(uSpecular, R, rough * (mips - 1.0)).rgb; + vec2 ab = texture(uBrdfLut, vec2(NdotV, rough)).rg; + return pref * (F0 * ab.x + ab.y); +} + +void main() { + mat = gMaterials[pc.idx.x].m[pc.idx.y]; + vec4 base = mat.base * texture(tBase, uv_of(0)); // sRGB 뷰라 샘플링이 선형으로 돌려준다 + if (mat.alpha.y == 1.0 && base.a < mat.alpha.x) discard; + + vec4 mr = texture(tMetalRough, uv_of(1)); + float metallic = clamp(mat.mrno.x * mr.b, 0.0, 1.0); + float rough = clamp(mat.mrno.y * mr.g, 0.03, 1.0); + float occ = 1.0 + mat.mrno.w * (texture(tOcclusion, uv_of(3)).r - 1.0); + vec3 emissive = mat.emissive.rgb * texture(tEmissive, uv_of(4)).rgb; + + vec3 N = normalize(vNormal); + if (!gl_FrontFacing) N = -N; // 양면 재질 (차 메시는 컬링 없이 그린다) + vec3 V = normalize(pc.campos.xyz - vWorld); + if (mat.alpha.w > 0.5) { + vec3 T, B; + if (abs(vTangent.w) > 0.5) { + T = normalize(vTangent.xyz - N * dot(N, vTangent.xyz)); + B = cross(N, T) * vTangent.w; + } else { // 탄젠트가 없으면 화면 미분으로 (cotangent frame) + vec3 dp1 = dFdx(vWorld), dp2 = dFdy(vWorld); + vec2 uv = uv_of(2); + vec2 du1 = dFdx(uv), du2 = dFdy(uv); + vec3 dp2perp = cross(dp2, N), dp1perp = cross(N, dp1); + T = dp2perp * du1.x + dp1perp * du2.x; + B = dp2perp * du1.y + dp1perp * du2.y; + float inv = inversesqrt(max(dot(T, T), dot(B, B)) + 1e-12); + T *= inv; + B *= inv; + } + vec3 nt = texture(tNormal, uv_of(2)).xyz * 2.0 - 1.0; + nt.xy *= mat.mrno.z; + N = normalize(mat3(T, B, N) * nt); + } + + float NdotV = max(dot(N, V), 1e-4); + vec3 R = reflect(-V, N); + vec3 F0 = mix(vec3(0.04), base.rgb, metallic); + vec3 spec = ibl_specular(R, rough, F0, NdotV); + vec3 kS = fresnel_schlick_roughness(NdotV, F0, rough); + vec3 kD = (1.0 - kS) * (1.0 - metallic); + vec3 diffuse = texture(uIrradiance, N).rgb * base.rgb; + + // 투과(유리) 근사: 뒤쪽 장면 대신 환경맵을 시선 방향으로 비춘다 + 반투명 블렌딩 + float tr = mat.alpha.z; + vec3 body = kD * diffuse * (1.0 - tr); + if (tr > 0.0) { + float mips = float(textureQueryLevels(uSpecular)); + body += tr * (1.0 - kS) * textureLod(uSpecular, -V, rough * (mips - 1.0)).rgb * base.rgb; + } + vec3 lin = (body + spec) * occ + emissive; + + // 클리어코트: 거칠기 낮은 유전체(F0 0.04) 층이 위에 한 겹 더 (차 도장) + float cc = mat.cc.x; + if (cc > 0.0) { + float ccr = clamp(mat.cc.y, 0.03, 1.0); + vec3 Fc = fresnel_schlick_roughness(NdotV, vec3(0.04), ccr); + lin = lin * (1.0 - cc * Fc) + cc * ibl_specular(reflect(-V, normalize(vNormal) * (gl_FrontFacing ? 1.0 : -1.0)), + ccr, vec3(0.04), NdotV); + } + + lin *= pc.campos.w; + vec3 c = linear_to_srgb(compress_highlights(lin)); + float a = 1.0; + if (mat.alpha.y == 2.0) a = base.a; + if (tr > 0.0) a = mix(1.0, 0.35, tr) + 0.5 * kS.g * tr; // 유리: 정면은 비치고 비스듬하면 반사 + a = clamp(a, 0.0, 1.0); + outColor = vec4(c * a, a); // premultiplied + outDepth = vec4(linear_depth(gl_FragCoord.z) * a, a, 0.0, a); +} diff --git a/src/vkgs/shaders/pbr_gltf.vert b/src/vkgs/shaders/pbr_gltf.vert new file mode 100644 index 0000000000000000000000000000000000000000..bbb6cd942a8a9b2cd001722caff56a9e0e425367 --- /dev/null +++ b/src/vkgs/shaders/pbr_gltf.vert @@ -0,0 +1,36 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +// glTF 메시 (차량). 정점 = gltf.hpp 의 GltfVertex (56 B = float 14개) — bindless SSBO 에서 직접 읽는다 (vertex pulling) +#include "bindless.glsl" +layout(std430, set = 0, binding = 0) readonly buffer GltfVerts { float gv[]; } gGltfVerts[]; + +layout(push_constant) uniform PC { + mat4 model; // 메시 로컬 → 씬 (차량 박스 포즈 × 크기 맞춤 × glTF 축 변환) + mat4 viewProj; + vec4 campos; // xyz 카메라 위치, w 노출 배율 + ivec4 idx; // x,y = 재질 (pbr_gltf.frag), z = 정점 SSBO 슬롯 +} pc; + +layout(location = 0) out vec3 vWorld; +layout(location = 1) out vec3 vNormal; +layout(location = 2) out vec2 vUV0; +layout(location = 3) out vec2 vUV1; +layout(location = 4) out vec4 vTangent; + +float vtx(uint k) { return gGltfVerts[uint(pc.idx.z)].gv[uint(gl_VertexIndex) * 14u + k]; } + +void main() { + vec3 inPos = vec3(vtx(0u), vtx(1u), vtx(2u)); + vec3 inNormal = vec3(vtx(3u), vtx(4u), vtx(5u)); + vec2 inUV0 = vec2(vtx(6u), vtx(7u)); + vec2 inUV1 = vec2(vtx(8u), vtx(9u)); + vec4 inTangent = vec4(vtx(10u), vtx(11u), vtx(12u), vtx(13u)); + vec4 w = pc.model * vec4(inPos, 1.0); + vWorld = w.xyz; + mat3 m = mat3(pc.model); // 균일 스케일만 쓰므로 법선도 이걸로 충분 + vNormal = normalize(m * inNormal); + vTangent = vec4(normalize(m * inTangent.xyz + 1e-9), inTangent.w); + vUV0 = inUV0; + vUV1 = inUV1; + gl_Position = pc.viewProj * w; +} diff --git a/src/vkgs/shaders/precip.frag b/src/vkgs/shaders/precip.frag new file mode 100644 index 0000000000000000000000000000000000000000..43ab854e0a79d53e9aa2298540d76e20c60c1299 --- /dev/null +++ b/src/vkgs/shaders/precip.frag @@ -0,0 +1,27 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +#extension GL_EXT_shader_image_load_formatted : require +// 강수 입자 프래그먼트 — 모양(빗줄기 캡슐·눈송이 원판) × 장면에 가려짐(거리 버퍼와 부드럽게 비교). +// 결과는 premultiplied 로 precip 레이어에 쌓이고, weather.comp 가 안개 뒤·렌즈 앞에 합성한다. +#include "bindless.glsl" +#include "depth_common.glsl" +layout(set = 0, binding = 5) readonly uniform image2D gImgF1[]; + +layout(location = 0) in vec2 vLocal; +layout(location = 1) in float vDepth; +layout(location = 2) in vec4 vColor; +layout(location = 3) flat in uint vMode; +layout(location = 4) flat in float vEnd; +layout(location = 0) out vec4 outColor; + +void main() { + // 캡슐 거리 (반폭 단위): 길이 방향으로 끝에서 반폭만큼 안쪽까지는 0 + float along = max(abs(vLocal.y) - (vEnd - 1.0), 0.0); + float r = length(vec2(vLocal.x, along)); + float cov = 1.0 - smoothstep(vMode == 1u ? 0.35 : 0.5, 1.0, r); + vec2 acc = imageLoad(gImgF1[kImgDepthAcc], ivec2(gl_FragCoord.xy)).rg; + // 장면 표면보다 앞이면 보인다. 표면이 반투명(덮임 < 1)이면 뒤에 있어도 그만큼 비친다 + float behind = 1.0 - smoothstep(-0.25, 0.25, accum_depth(acc) - vDepth); + float vis = 1.0 - accum_cover(acc) * behind; + outColor = vColor * (cov * vis); +} diff --git a/src/vkgs/shaders/precip.vert b/src/vkgs/shaders/precip.vert new file mode 100644 index 0000000000000000000000000000000000000000..fb0e0c41a914388b3c4b379e940a6ec50934fb09 --- /dev/null +++ b/src/vkgs/shaders/precip.vert @@ -0,0 +1,110 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +// 강수 입자 (비·눈) — 월드 공간. 입자 하나 = 인스턴스 하나 = 화면 사각형 하나 (triangle strip 4정점). +// +// 상태 없이 (id, 시각) 의 해시로 위치를 정한다: 씬 좌표에 고정된 격자 상자(반폭 W.p6.zzw)를 카메라 주변에서 +// 감아 돌린다 — 그래서 빗방울이 세상에 붙어 있고 (차가 달리면 시차), 6대 카메라가 같은 빗방울을 본다. +// 빗줄기: 노출 시간 동안의 (낙하 + 바람 - 자차) 상대 이동을 화면에 펼친 선 — 달리면 비스듬해진다. +// 눈송이: 둥근 원판. 가까울수록 초점이 안 맞아 크고 흐려진다 (조리개 지름 W.p6.x, 초점 무한대). +// 밝기: 하늘빛(시간대) + 전조등. 장면에 가려지는지는 precip.frag 가 거리 버퍼로 판정한다. +#include "bindless.glsl" +#include "weather_common.glsl" + +layout(push_constant) uniform Push { uint mode; uint count; } pc; // mode 0 비, 1 눈 + +layout(location = 0) out vec2 vLocal; // 사각형 안 좌표 (-1~1): x = 폭 방향, y = 길이 방향 +layout(location = 1) out float vDepth; // 카메라 z (m) +layout(location = 2) out vec4 vColor; // premultiplied (빛 × 불투명도), 안개 감쇠까지 +layout(location = 3) flat out uint vMode; +layout(location = 4) flat out float vEnd; // 반길이 / 반폭 (끝 둥글리기) + +uint pcg(uint v) { + uint s = v * 747796405u + 2891336453u; + uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; + return (w >> 22u) ^ w; +} +float rnd(uint id, uint salt) { return float(pcg(id * 9781u + salt * 6271u + 17u)) * (1.0 / 4294967296.0); } + +vec2 to_px(vec3 q) { return vec2(W.intr.x * q.x / q.z + W.intr.z, W.intr.y * q.y / q.z + W.intr.w); } + +void cull() { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); vColor = vec4(0.0); vLocal = vec2(0.0); vDepth = 0.0; vEnd = 1.0; } + +void main() { + uint id = gl_InstanceIndex; + bool snow = pc.mode == 1u; + vMode = pc.mode; + float t = W.p1.y; + vec3 H = vec3(W.p6.z, W.p6.z, W.p6.w); + vec3 seed = vec3(rnd(id, 1u), rnd(id, 2u), rnd(id, 3u)) * 2.0 * H; + float fall = snow ? mix(0.7, 1.4, rnd(id, 4u)) : mix(6.5, 9.0, rnd(id, 4u)); + vec2 wind = W.p5.xy * (snow ? 1.0 : 0.7); + vec3 vel = vec3(wind, -fall); + vec3 pos = seed + vel * t; + if (snow) { // 흔들리며 내린다 + float ph = rnd(id, 5u) * 6.2832, f = mix(0.6, 1.4, rnd(id, 6u)); + pos.xy += 0.35 * vec2(sin(t * f + ph), cos(t * 0.8 * f + ph * 1.7)); + vel.xy += 0.35 * f * vec2(cos(t * f + ph), -0.8 * sin(t * 0.8 * f + ph * 1.7)); + } + vec3 c = W.campos.xyz; + vec3 p = c + mod(pos - c + H, 2.0 * H) - H; + // 상자 가장자리에서 서서히 (카메라마다 상자 중심이 조금 달라 감기는 자리가 다르다) + vec3 rel = abs(p - c) / H; + float edge = 1.0 - smoothstep(0.75, 1.0, max(rel.x, max(rel.y, rel.z))); + + mat3 R = transpose(mat3(W.c2w)); + vec3 q = R * (p - c); + const float zmin = 0.25; + if (q.z < zmin || edge <= 0.0) { cull(); return; } + vec2 a = to_px(q); + float fx = W.intr.x; + // 초점 흐림 (초점 무한대): 착란원 지름 px = fx · 조리개 / 거리 + float coc = fx * W.p6.x / q.z; + float alpha, len_px; + vec2 b, along; + float half_w; + if (!snow) { + // 노출 동안 카메라 기준 상대 이동 → 빗줄기 꼬리 + vec3 rv = vel - W.p4.xyz; + vec3 q2 = R * (p - rv * W.p4.w - c); + if (q2.z < zmin) q2 = q + (q2 - q) * ((q.z - zmin) / max(q.z - q2.z, 1e-6)); // 꼬리를 z = zmin 에서 자른다 + b = to_px(q2); + float sharp = fx * 0.0015 / q.z; // 빗방울 지름 약 1.5 mm + float w = max(max(sharp, coc), 1.0); + // 1 px 보다 가늘거나 흐리면 그만큼 옅다. 빗방울은 빛을 모아 굴절시켜 실제 지름보다 밝게 찍힌다 + alpha = 0.85 * clamp(2.5 * sharp / w, 0.12, 1.0); + half_w = 0.5 * w + 0.5; + } else { + b = a; + float sharp = fx * mix(0.008, 0.02, pow(rnd(id, 7u), 2.0)) / q.z; // 송이(뭉친 결정) 지름 8~20 mm, 작은 것이 많다 + float w = max(max(sharp, coc), 1.2); + alpha = 0.9 * clamp(sharp * sharp / (w * w), 0.03, 1.0); // 흐리면 같은 빛이 넓게 퍼진다 + half_w = 0.5 * w + 0.5; + } + vec2 d = b - a; + float L = length(d); + along = L > 1e-3 ? d / L : vec2(0.0, 1.0); + vec2 across = vec2(-along.y, along.x); + // 사각형: 길이 방향으로 [a - half_w, b + half_w], 폭 ±half_w + uint v = gl_VertexIndex; + float sx = (v & 1u) == 0u ? -1.0 : 1.0, sy = (v & 2u) == 0u ? -1.0 : 1.0; + vec2 mid = 0.5 * (a + b); + vec2 px = mid + along * sy * (0.5 * L + half_w) + across * sx * half_w; + vEnd = (0.5 * L + half_w) / half_w; + vLocal = vec2(sx, sy * vEnd); // 둘 다 반폭 단위 + vDepth = q.z; + + // 빛: 하늘빛 (시간대·구름) + 전조등 (빗방울은 빛을 앞으로 강하게 산란한다) + float cloud = clamp(max(W.p0.y, 0.85 * W.p2.x), 0.0, 1.0); + vec3 sky = vec3(0.62, 0.66, 0.72) * day_exposure() * day_tint() * (1.0 - 0.35 * cloud); + float irr = headlight_irr(p); + // 눈송이는 흰 산란체라 하늘빛을 거의 다 돌려보낸다 — 흐린 배경보다 밝게. 빗방울은 주변 빛을 굴절해 밝다 + vec3 col = snow ? (vec3(0.95, 0.97, 1.0) * 1.5 * day_exposure() * mix(vec3(1.0), day_tint(), 0.5) + irr * vec3(1.0, 0.97, 0.92) * 1.5) + : sky * 1.1 + irr * vec3(1.0, 0.97, 0.9) * 2.5; + float amount = snow ? clamp(W.p2.x, 0.0, 1.0) : clamp(W.p0.z, 0.0, 1.0); + // 강도는 개수로 정하지만, 약할 때는 각 입자도 조금 옅게 + alpha *= edge * (0.55 + 0.45 * amount); + float T = exp(-fog_optical_depth(c, p)); // 입자 앞의 안개 + vColor = vec4(col * alpha * T, alpha * T); + vec2 ndc = px / W.size.xy * 2.0 - 1.0; + gl_Position = vec4(ndc, 0.5, 1.0); +} diff --git a/src/vkgs/shaders/shadow.frag b/src/vkgs/shaders/shadow.frag new file mode 100644 index 0000000000000000000000000000000000000000..9624f1a25a1e000014014e09d4049fdee4d3acfb --- /dev/null +++ b/src/vkgs/shaders/shadow.frag @@ -0,0 +1,18 @@ +#version 450 +layout(location = 0) in vec2 vUV; +layout(location = 0) out vec4 outColor; +layout(push_constant) uniform PC { + mat4 viewProj; + mat4 box; + vec4 params; +} pc; +void main() { + // 둥근 사각형 거리장 (박스 단위). 안쪽은 진하고 가장자리로 부드럽게 사라진다 + // 차 바닥보다 10% 큰 사각형까지 진하게, 그 밖으로 부드럽게 — 차 둘레에 접지 그늘이 보이도록. + // (차 크기 그대로면 진한 부분이 전부 차체 밑에 숨어 안 보였다) + float r = 0.15; + vec2 q = abs(vUV) - vec2(0.55 - r); + float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; + float a = pc.params.x * (1.0 - smoothstep(-pc.params.y, pc.params.y, d)); + outColor = vec4(0.0, 0.0, 0.0, a); // premultiplied 검정 = 아래를 a 만큼 어둡게 +} diff --git a/src/vkgs/shaders/shadow.vert b/src/vkgs/shaders/shadow.vert new file mode 100644 index 0000000000000000000000000000000000000000..676a70906e505c2f1fc21ff5fdb481ca904f2ec3 --- /dev/null +++ b/src/vkgs/shaders/shadow.vert @@ -0,0 +1,13 @@ +#version 450 +// 차량 접지 그림자 — 차 바닥 크기의 둥근 사각형을 지면에 깐다. 3DGS 노면 위에 어둡게 블렌딩. +layout(push_constant) uniform PC { + mat4 viewProj; + mat4 box; // 차 박스 바닥 중앙 → 씬, x/y 축에 길이·폭이 곱해져 있다 + vec4 params; // x 불투명도, y 가장자리 부드러움, z 바깥 여유 배율 +} pc; +layout(location = 0) out vec2 vUV; // 박스 로컬 [-0.5, 0.5] × 여유 +void main() { + vec2 c = vec2(float(gl_VertexIndex & 1), float(gl_VertexIndex >> 1)) * 2.0 - 1.0; + vUV = c * 0.5 * pc.params.z; + gl_Position = pc.viewProj * (pc.box * vec4(vUV, 0.02, 1.0)); +} diff --git a/src/vkgs/shaders/sky_sim.frag b/src/vkgs/shaders/sky_sim.frag new file mode 100644 index 0000000000000000000000000000000000000000..7751610fc757fac6af3afce0ea72db282a68ea91 --- /dev/null +++ b/src/vkgs/shaders/sky_sim.frag @@ -0,0 +1,97 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +// 시뮬레이션 하늘 — 3DGS 로 하늘을 그리지 않는다 (사용자 결정 2026-10-05). 학습은 하늘을 분리(--sky)해 스플랫 α 를 +// 하늘에서 0 으로 비우고, 렌더링 때는 이 셰이더가 시간대·구름·날씨에 맞춰 배경 하늘을 그린다. +// 날씨 UBO(weather.comp 와 같은 블록)의 값을 읽는다: 밝기(해 고도), 구름, 비, 눈, 해 방위. +// 출력은 3DGS 와 같은 카메라 sRGB 공간. 거리 누적에는 kSkyDepth (무한히 먼 곳). +#include "bindless.glsl" +#include "depth_common.glsl" +layout(location = 0) in vec2 vPix; +layout(location = 0) out vec4 outColor; +layout(push_constant) uniform PC { + mat4 c2w_rot; + vec4 intr; + vec4 size; + ivec4 idx; +} pc; +layout(std140, set = 0, binding = 1) uniform Weather { + vec4 p0, p1, campos; + mat4 c2w; + vec4 intr, size, hl_pos, hl_dir, p2, p3; +} gWeather[]; +#define W gWeather[kUboWeather] + +float hash3(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); } +float noise3(vec3 p) { + vec3 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(mix(hash3(i), hash3(i + vec3(1, 0, 0)), f.x), mix(hash3(i + vec3(0, 1, 0)), hash3(i + vec3(1, 1, 0)), f.x), f.y), + mix(mix(hash3(i + vec3(0, 0, 1)), hash3(i + vec3(1, 0, 1)), f.x), mix(hash3(i + vec3(0, 1, 1)), hash3(i + vec3(1, 1, 1)), f.x), f.y), + f.z); +} +float fbm(vec3 p) { + float a = 0.5, s = 0.0; + for (int k = 0; k < 5; ++k) { + s += a * noise3(p); + p *= 2.03; + a *= 0.5; + } + return s; +} +vec3 to_srgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c)); +} + +void main() { + vec3 dc = vec3((vPix.x - pc.intr.z) / pc.intr.x, (vPix.y - pc.intr.w) / pc.intr.y, 1.0); + vec3 d = normalize(mat3(pc.c2w_rot) * dc); // 씬 좌표 (z 위) + float light = clamp(W.p0.x, 0.0, 1.0); + float rain = clamp(W.p0.z, 0.0, 1.0), snow = clamp(W.p2.x, 0.0, 1.0); + float cloud = clamp(max(W.p0.y, max(0.9 * rain, 0.85 * snow)), 0.0, 1.0); + float time = W.p1.y; + + // 해: 밝기 → 고도 (밤 −14°, 해질녘 약 5°, 한낮 57°), 방위는 p2.w (도) + float elev = mix(-0.25, 1.0, light); + float az = radians(W.p2.w); + vec3 sun = normalize(vec3(cos(elev) * cos(az), cos(elev) * sin(az), sin(elev))); + float day = smoothstep(-0.12, 0.25, sun.z); // 낮 정도 + float dusk = exp(-pow((sun.z - 0.05) / 0.12, 2.0)); // 해가 지평선 근처일 때 + + // 하늘 기울기 (선형) + vec3 zen = mix(vec3(0.0015, 0.0025, 0.008), vec3(0.16, 0.36, 0.78), day); + vec3 hor = mix(vec3(0.006, 0.008, 0.015), vec3(0.70, 0.80, 0.92), day); + hor = mix(hor, vec3(1.0, 0.52, 0.26), 0.75 * dusk); + zen = mix(zen, vec3(0.20, 0.24, 0.48), 0.5 * dusk); + float t = clamp(d.z, 0.0, 1.0); + vec3 sky = mix(hor, zen, pow(t, 0.45)); + if (d.z < 0.0) sky = hor * mix(0.75, 0.55, clamp(-d.z * 4.0, 0.0, 1.0)); // 지평선 아래 (먼 지면 안개) + + // 해 원반·광륜 + float cs = max(dot(d, sun), 0.0); + vec3 sunc = mix(vec3(1.0, 0.55, 0.3), vec3(1.0, 0.95, 0.88), smoothstep(0.0, 0.3, sun.z)); + sky += sunc * (0.25 * pow(cs, 8.0) + 0.6 * pow(cs, 200.0)) * day * (1.0 - 0.85 * cloud); + sky += sunc * 20.0 * smoothstep(0.9997, 0.99985, cs) * day * (1.0 - cloud); + + // 별 (밤, 구름이 적을 때) + if (day < 0.3 && d.z > 0.0) { + vec3 g = floor(d * 400.0); + float st = step(0.9975, hash3(g)) * (0.5 + 0.5 * sin(time * 3.0 + hash3(g + 1.0) * 30.0)); + sky += vec3(0.6) * st * (1.0 - day / 0.3) * (1.0 - cloud); + } + + // 구름: 하늘을 평면(높이 1)에 투영한 좌표의 FBM. 시간에 따라 흐른다. + if (d.z > 0.01) { + vec2 uv = d.xy / (d.z + 0.15) * 1.6; + float n = fbm(vec3(uv + time * W.p3.xy, time * 0.01)); + float cover = mix(0.12, 1.05, cloud); + float c = smoothstep(1.0 - cover, 1.0 - cover + 0.35, n) * smoothstep(0.01, 0.12, d.z); + float lit = 0.55 + 0.45 * pow(cs, 2.0); + vec3 cl_day = mix(vec3(0.95, 0.96, 0.98) * lit, vec3(0.50, 0.52, 0.55), cloud * cloud); // 흐릴수록 회색 + vec3 cl = mix(vec3(0.004, 0.005, 0.009), cl_day, day); + cl = mix(cl, vec3(1.0, 0.62, 0.42) * 0.8, 0.5 * dusk * (1.0 - cloud)); + sky = mix(sky, cl, c); + sky = mix(sky, cl * 0.95, 0.85 * cloud * cloud); // 잔뜩 흐리면 하늘 전체가 구름 + } + outColor = vec4(to_srgb(sky), 1.0); +} diff --git a/src/vkgs/shaders/smoke.comp b/src/vkgs/shaders/smoke.comp new file mode 100644 index 0000000000000000000000000000000000000000..bfd9570c647cdb261999c47e4f8b570b12b8667c --- /dev/null +++ b/src/vkgs/shaders/smoke.comp @@ -0,0 +1,22 @@ +#version 450 +// 최소 컴퓨트 셰이더. 셰이더 컴파일 → 디스크립터 바인딩 → 디스패치 → 읽기 +// 라는 경로 전체를 확인하기 위한 것이다. 각 원소에 gl_GlobalInvocationID 를 더한다. +// +// subgroup 크기도 함께 내보낸다. 3DGS 타일 래스터라이저의 per-tile 정렬과 +// 알파 블렌딩이 subgroup 연산에 의존하므로, NVIDIA 에서 32 가 나와야 정상이다. + +layout(local_size_x = 64) in; + +layout(std430, binding = 0) buffer Data { + uint values[]; +}; + +layout(push_constant) uniform Push { + uint count; +} pc; + +void main() { + uint i = gl_GlobalInvocationID.x; + if (i >= pc.count) return; + values[i] = values[i] * 2u + i; +} diff --git a/src/vkgs/shaders/weather.comp b/src/vkgs/shaders/weather.comp new file mode 100644 index 0000000000000000000000000000000000000000..c7807b34878f096cd37f0e511215cc9a85b0ac8c --- /dev/null +++ b/src/vkgs/shaders/weather.comp @@ -0,0 +1,332 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require +#extension GL_EXT_shader_image_load_formatted : require +// 합성 날씨·조도 후처리 — 주간 3DGS 렌더(색 + 거리 누적)에서 밤·흐림·비·안개를 만든다. +// 재구성은 싱가포르 주간 씬만 쓰고 조건은 여기서 바꾼다 (docs/roadmap.md '전체 맵 + 합성 날씨'). +// 꺼져 있으면(enabled = 0) 입력을 그대로 복사한다 — 학습기와의 일치 검사(nusim render compare)가 그대로다. +// +// 근사의 한계 (알고 쓰자): +// - 3DGS 색 = 낮의 '조명이 구워진' 색이라 진짜 반사율이 아니다. 밤·전조등은 그 색을 반사율 대신 쓴다. +// - 구워진 그림자는 흐림에서도 남는다 (대비만 줄인다). +// - 젖은 노면 반사는 화면 공간 반사라 화면 밖·가려진 것은 비치지 않는다. +#include "depth_common.glsl" +layout(local_size_x = 8, local_size_y = 8) in; + +#include "bindless.glsl" +// 형식 없는 storage image — 실제 형식(16/32비트)은 렌더 그래프 JSON 이 정한다 +layout(set = 0, binding = 4) uniform image2D gImgF4[]; +layout(set = 0, binding = 5) uniform image2D gImgF1[]; +#define uColor gImgF4[kImgTarget] +#define uOut gImgF4[kImgPost] +#define uDepth gImgF1[kImgDepthAcc] +#include "weather_common.glsl" +// slots.x = 강수 입자 레이어 (rgba, premultiplied 선형 — precip.vert/frag), slots.y = 배경(하늘) 이미지 +layout(push_constant) uniform Push { uvec4 slots; vec4 ctx; } pc; +#define uPrecip gImgF4[pc.slots.x] +#define uBg gImgF4[pc.slots.y] + +// 장면 색은 투명 배경 위에 쌓은 premultiplied (α = 덮임), 배경(하늘)은 따로 그린다 (렌더 그래프 background 패스). +// 날씨 없이 합친 색 — 예전 렌더 패스 블렌딩(배경 먼저, 스플랫 over)과 같은 식. +vec3 composite(ivec2 q) { + vec4 c = imageLoad(uColor, q); + return c.rgb + (1.0 - c.a) * imageLoad(uBg, q).rgb; +} + +vec3 ray_cam(vec2 px) { return vec3((px.x - W.intr.z) / W.intr.x, (px.y - W.intr.w) / W.intr.y, 1.0); } + +// 씬 점 → (픽셀, 카메라 z). 카메라 뒤면 z <= 0 +vec3 project(vec3 p) { + vec3 q = transpose(mat3(W.c2w)) * (p - W.campos.xyz); + return vec3(W.intr.x * q.x / q.z + W.intr.z, W.intr.y * q.y / q.z + W.intr.w, q.z); +} + +// 화면 공간 반사: 젖은 노면 점 p 에서 반사 방향 r 로 걸어가며 거리 버퍼와 부딪히는 곳의 색 +bool ssr(vec3 p, vec3 r, out vec3 hit, out float fade) { + float t = 0.3, t_prev = 0.0; + for (int k = 0; k < 32; ++k) { + vec3 s = project(p + r * t); + if (s.z <= kNear || s.x < 0.0 || s.y < 0.0 || s.x >= W.size.x || s.y >= W.size.y) return false; + float d = accum_depth(imageLoad(uDepth, ivec2(s.xy)).rg); + if (s.z > d) { + // 이분 탐색으로 표면을 좁힌다 — 걸음이 커서 생기던 하얀 반짝이(엉뚱한 픽셀) 방지 + float a = t_prev, b = t; + for (int j = 0; j < 5; ++j) { + float m = 0.5 * (a + b); + vec3 sm = project(p + r * m); + if (sm.z > accum_depth(imageLoad(uDepth, ivec2(sm.xy)).rg)) b = m; else a = m; + } + s = project(p + r * b); + float dd = accum_depth(imageLoad(uDepth, ivec2(s.xy)).rg); + if (s.z - dd > 0.25 + 0.04 * b) return false; // 물체 뒤로 숨어든 것 — 반사 없음 + hit = to_lin(composite(ivec2(s.xy))); + vec2 e = min(s.xy, W.size.xy - s.xy) / (0.08 * W.size.xy); + fade = clamp(min(e.x, e.y), 0.0, 1.0); // 화면 가장자리에서 서서히 + return true; + } + t_prev = t; + t *= 1.15; + } + return false; +} + +// 픽셀의 씬 점 (거리 버퍼로) — 표면 법선 추정용 +vec3 world_at(ivec2 q) { + q = clamp(q, ivec2(0), ivec2(W.size.xy) - 1); + return W.campos.xyz + mat3(W.c2w) * (ray_cam(vec2(q) + 0.5) * accum_depth(imageLoad(uDepth, q).rg)); +} + +// 값 잡음 (부드러운) — 물웅덩이 모양 +float vnoise(vec2 p) { + vec2 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); +} +float fbm(vec2 p) { return 0.55 * vnoise(p) + 0.3 * vnoise(p * 2.1 + 3.7) + 0.15 * vnoise(p * 4.3 + 9.1); } + +// 빗방울 파문: 노면 격자(칸마다 무작위 위상)에서 퍼지는 고리 → 법선 기울기 (xy). 두 겹 +vec2 ripples(vec2 wp, float t, float amount) { + vec2 g = vec2(0.0); + for (int k = 0; k < 2; ++k) { + float cell = k == 0 ? 0.35 : 0.23; + vec2 q = wp / cell + float(k) * 17.3; + vec2 id = floor(q); + for (int dy = -1; dy <= 1; ++dy) + for (int dx = -1; dx <= 1; ++dx) { + vec2 nid = id + vec2(dx, dy); + float h = hash(nid), h2 = hash(nid + 31.7); + if (h2 > amount) continue; + vec2 c = nid + 0.5 + 0.35 * vec2(h - 0.5, hash(nid + 7.1) - 0.5); + float ph = fract(t * (0.9 + 0.6 * h) + h * 7.0); // 0 → 1 동안 고리가 퍼지고 사라진다 + vec2 v = q - c; + float r = length(v); + float ring = r - ph * 0.9; + float w = exp(-ring * ring * 60.0) * (1.0 - ph) * (1.0 - ph); + g += (r > 1e-4 ? v / r : vec2(0.0)) * sin(ring * 28.0) * w; + } + } + return g * 0.35; +} + +// 렌즈 물방울: 화면에 붙은 크고 작은 물방울이 천천히 흘러내린다. 물방울 안은 뒤 장면이 뒤집혀·흐리게 비친다. +// 반환: xy = 굴절로 읽을 픽셀 오프셋, z = 물방울 가림(0~1), w = 테두리 어둡기 +vec4 lens_drops(vec2 px, float amount, float t) { + vec4 best = vec4(0.0); + for (int k = 0; k < 2; ++k) { + float cell = k == 0 ? 90.0 : 46.0; + vec2 q = px / cell; + vec2 id = floor(q); + for (int dy = -1; dy <= 1; ++dy) + for (int dx = -1; dx <= 1; ++dx) { + vec2 nid = id + vec2(dx, dy) + float(k) * 41.0; + float h = hash(nid), h2 = hash(nid + 13.3); + if (h2 > amount * (k == 0 ? 0.05 : 0.08)) continue; // 한 화면에 큰 것 수 개, 작은 것 수십 개 + float slide = h > 0.75 ? fract(t * 0.05 * h) * 0.6 : 0.0; // 큰 것 일부는 흘러내린다 (이웃 칸 탐색 안에서) + vec2 c = nid - float(k) * 41.0 + vec2(0.5 + 0.4 * (hash(nid + 3.1) - 0.5), 0.5 + 0.4 * (h - 0.5) + slide); + float rad = mix(0.12, 0.38, hash(nid + 5.9)) * (k == 0 ? 1.0 : 0.7); + vec2 v = (q - c) / rad; + v.y *= 0.9 + 0.25 * hash(nid + 2.2); // 조금 찌그러진 방울 + float r2 = dot(v, v); + if (r2 >= 1.0) continue; + float inside = 1.0 - smoothstep(0.75, 1.0, sqrt(r2)); + if (inside > best.z) { + // 볼록 렌즈: 가운데는 뒤집힌 상, 가장자리로 갈수록 크게 휜다 + vec2 off = -v * rad * cell * (1.6 + 0.8 * r2); + best = vec4(off, inside, smoothstep(0.6, 1.0, sqrt(r2))); + } + } + } + return best; +} + +void main() { + ivec2 ip = ivec2(gl_GlobalInvocationID.xy); + if (ip.x >= int(W.size.x) || ip.y >= int(W.size.y)) return; + vec4 src = imageLoad(uColor, ip); + vec3 bg_srgb = imageLoad(uBg, ip).rgb; + if (W.p1.z < 0.5) { + imageStore(uOut, ip, vec4(src.rgb + (1.0 - src.a) * bg_srgb, 1.0)); + return; + } + // 덮임 α — 이만큼이 가우시안·메시, 나머지 1 − α 가 배경(하늘). 두 부분을 따로 보정해 마지막에 합친다 + float a = clamp(src.a, 0.0, 1.0); + float light = clamp(W.p0.x, 0.0, 1.0), cloud = clamp(W.p0.y, 0.0, 1.0); + float rain = clamp(W.p0.z, 0.0, 1.0), fog = clamp(W.p0.w, 0.0, 1.0); + float wet = max(clamp(W.p1.x, 0.0, 1.0), rain); + float snow = clamp(W.p2.x, 0.0, 1.0), cover = clamp(max(W.p2.y, snow * 0.8), 0.0, 1.0); + vec2 px = vec2(ip) + 0.5; + + vec2 acc = imageLoad(uDepth, ip).rg; + float zc = accum_depth(acc); // 표면 깊이 (m) + // 하늘: 배경(kSkyDepth), 또는 '지평선 위 + 밝고 + 꽤 먼' 픽셀. 3DGS 하늘 구(씬 반경 × 2) 앞에 반투명 + // 가우시안이 겹쳐 기대 깊이가 수십 m 로 나오는 곳이 많아 깊이만으로는 못 가른다 (scene-0003 실측: 흐림·밤에 + // 하늘이 하얗게 남음). 연속값으로 둬서 나무 끝·건물 윤곽이 툭 끊기지 않게 한다. + float up = normalize(mat3(W.c2w) * ray_cam(vec2(ip) + 0.5)).z; + vec3 s_srgb = a > 1e-4 ? src.rgb / a : vec3(0.0); // 가우시안 부분의 색 (premultiplied 를 푼다) + float bright = smoothstep(0.45, 0.75, luma(to_lin(s_srgb))); + // 가우시안 부분 중 '하늘을 구운 가우시안'(먼 하늘 구, 지평선 위의 밝고 먼 덩어리) — 하늘을 따로 학습하지 않은 모델 + float skyw = max(smoothstep(150.0, 400.0, zc), smoothstep(25.0, 60.0, zc) * smoothstep(0.0, 0.06, up) * bright); + bool sky = skyw > 0.5; + if (W.p1.z > 1.5) { // 디버그 (VKGS_WEATHER_DEBUG): R = 거리/200 m, G = 하늘 판정, B = 덮임 + imageStore(uOut, ip, vec4(clamp(zc / 200.0, 0.0, 1.0), skyw, a, 1.0)); + return; + } + vec3 rc = ray_cam(px); + vec3 dir = normalize(mat3(W.c2w) * rc); // 씬 좌표 시선 방향 + vec3 p = W.campos.xyz + mat3(W.c2w) * (rc * zc); // 픽셀의 씬 점 + float range = sky ? 1e4 : length(rc * zc); + + vec3 day = to_lin(s_srgb); // 낮의 색 (= 반사율 대용) + vec3 lin = day; + + // 1. 구름: 채도·대비·밝기를 낮추고 하늘을 회색 구름으로 (눈이 오면 하늘은 늘 잔뜩 흐리다) + cloud = max(cloud, 0.85 * snow); + // (가우시안 부분만 — 배경 하늘은 아래 6에서 따로. 낮 하늘색이 구워진 가우시안도 밤에는 어둡게 바꾼다) + float L = luma(lin); + lin = mix(lin, vec3(L), 0.55 * cloud) * (1.0 - 0.3 * cloud); + float skyg = skyw; + lin = mix(lin, vec3(0.42 + 0.25 * (1.0 - cloud)) * (0.55 + 0.45 * L), 0.85 * cloud * skyg); + + // 2. 시간대: 노출과 색온도. 낮 → 해질녘(따뜻) → 밤(푸르고 어둡다) + float e = mix(0.012, 1.0, pow(light, 1.6)); + vec3 tint = mix(vec3(0.55, 0.68, 1.0), vec3(1.08, 0.88, 0.72), smoothstep(0.08, 0.45, light)); + tint = mix(tint, vec3(1.0), smoothstep(0.55, 0.95, light)); + lin *= e * tint; + lin = mix(lin, mix(vec3(0.002, 0.003, 0.008), lin, smoothstep(0.05, 0.5, light)), skyg); + + // 3. 전조등: 자차 앞 원뿔 안의 점을 반사율(낮 색) × 조도로 밝힌다 + if (W.hl_pos.w > 0.0 && !sky) { + vec3 v = p - W.hl_pos.xyz; + float d = length(v); + float c = dot(v / max(d, 1e-3), W.hl_dir.xyz); + float cone = smoothstep(W.hl_dir.w, mix(W.hl_dir.w, 1.0, 0.55), c); + float irr = W.hl_pos.w * cone / (1.0 + 0.09 * d * d); // 5 m 에서 약 0.3, 15 m 에서 0.04 (× 세기) + lin += day * irr * vec3(1.0, 0.97, 0.9); + } + + // 4. 비: 노면이 젖는다. 젖은 거친 면은 빛이 물막 안에서 여러 번 흡수돼 어둡고 채도가 오른다 (Lekner–Dorf). + // 오목한 곳엔 물웅덩이(값 잡음 마스크)가 고여 거울처럼 비추고, 그 위로 빗방울 파문이 퍼진다. + // 반사는 화면 공간 반사 (화면 밖·가려진 것은 비치지 않는다 — 그 몫은 하늘빛). + if (wet > 0.0 && !sky) { + float ground = 1.0 - smoothstep(0.08, 0.45, p.z - W.p1.w); + float g = wet * ground; + float Lw = luma(lin); + vec3 sat = mix(vec3(Lw), lin, 1.0 + 0.35 * g); + lin = max(sat, 0.0) * mix(1.0, 0.42 + 0.3 * (1.0 - clamp(Lw * 2.0, 0.0, 1.0)), g); + if (g > 0.01) { + float puddle = W.p5.w * smoothstep(0.62 - 0.22 * wet, 0.72 - 0.2 * wet, fbm(p.xy * 0.45)); + vec3 nrm = vec3(0.0, 0.0, 1.0); + if (rain > 0.0) nrm.xy += ripples(p.xy, W.p1.y, rain) * mix(0.25, 1.0, puddle); + nrm = normalize(nrm); + vec3 r = reflect(dir, nrm); + float fres = 0.02 + 0.98 * pow(1.0 - clamp(dot(-dir, nrm), 0.0, 1.0), 5.0); + vec3 env = mix(vec3(0.03), vec3(0.45, 0.47, 0.5), light) * (1.0 - 0.5 * cloud); + vec3 refl; + float fade = 0.0; + if (ssr(p + vec3(0.0, 0.0, 0.02), r, refl, fade)) refl = mix(env, min(refl, vec3(2.0)) * e * tint, fade); + else refl = env; + // 전조등의 젖은 노면 반짝임 (정반사 꼬리) + if (W.hl_pos.w > 0.0) { + vec3 l = normalize(W.hl_pos.xyz - p); + float spec = pow(max(dot(reflect(-l, nrm), -dir), 0.0), mix(40.0, 400.0, puddle)); + refl += headlight_irr(p) * spec * mix(2.0, 12.0, puddle) * vec3(1.0, 0.97, 0.9); + } + float k = g * mix(mix(0.12, 0.55, fres), mix(0.55, 0.95, fres), puddle); + lin = mix(lin, refl, k); + } + } + if (rain > 0.0) { + lin = mix(lin, vec3(luma(lin)), 0.25 * rain) * (1.0 - 0.2 * rain); + } + + // 4b. 쌓인 눈: 위를 향한 면에 쌓인다. 3DGS 기대 깊이는 잡음이 커서 이웃 1 px 로 법선을 구하면 나무·벽까지 + // '위를 향한다' 로 나온다 (scene-0003 실측: 화면 전체가 하얗게). 그래서 4 px 간격으로 구하고, 노면 높이 근처는 + // 느슨하게, 그 밖(지붕·화단 윗면)은 아주 평평한 곳만 덮는다. + if (cover > 0.0 && !sky) { + const int b = 4; + vec3 dx = world_at(ip + ivec2(b, 0)) - world_at(ip - ivec2(b, 0)); + vec3 dy = world_at(ip + ivec2(0, b)) - world_at(ip - ivec2(0, b)); + vec3 n = normalize(cross(dx, dy) + 1e-6); + if (dot(n, -dir) < 0.0) n = -n; // 카메라 쪽을 보게 + float near_ground = 1.0 - smoothstep(0.15, 0.5, p.z - W.p1.w); + float upf = near_ground * smoothstep(0.5, 0.8, n.z) + (1.0 - near_ground) * 0.35 * smoothstep(0.92, 0.98, n.z); + // 바퀴 자국·덜 쌓인 곳: 덩어리진 잡음으로 군데군데 비운다 + vec2 wp = p.xy * 1.7; + float patchy = 0.55 + 0.45 * hash(floor(wp)) * hash(floor(wp * 0.37) + 5.0); + float c = clamp(cover * upf * patchy * 1.2, 0.0, 1.0); + vec3 white = vec3(0.86, 0.9, 0.95) * e * mix(vec3(1.0), tint, 0.5); + lin = mix(lin, white * (0.75 + 0.25 * luma(day)), c); + } + if (snow > 0.0) { + lin = mix(lin, vec3(luma(lin)), 0.25 * snow) * vec3(0.95, 0.98, 1.04); // 차가운 색감 + } + + // 5. 안개 (비·눈이 오면 옅은 물안개도): 높이에 따라 옅어지는 안개 (해석적 광학 깊이). + // 안개 색 = 하늘빛 + 해 쪽 전방 산란 (Henyey–Greenstein, 낮·맑을수록) — 해를 보면 안개가 환하다. + float sigma = fog_sigma(); + vec3 fogc = mix(vec3(0.02, 0.025, 0.035), vec3(0.62, 0.65, 0.68), pow(light, 1.2)) * (1.0 - 0.25 * cloud); + if (sigma > 0.0) { + float az = radians(W.p2.w), el = asin(clamp(0.15 + 0.8 * light, 0.0, 0.95)); + vec3 sun = vec3(cos(el) * cos(az), cos(el) * sin(az), sin(el)); + float gHG = 0.6, cs = dot(dir, sun); + float hg = (1.0 - gHG * gHG) / pow(1.0 + gHG * gHG - 2.0 * gHG * cs, 1.5) / (4.0 * 3.14159); + fogc += vec3(1.0, 0.92, 0.8) * hg * 0.9 * light * (1.0 - cloud); + vec3 pend = sky ? W.campos.xyz + dir * 400.0 : p; + lin = mix(fogc, lin, exp(-fog_optical_depth(W.campos.xyz, pend))); + } + + // 6. 배경 하늘 (따로 그린 background). 시뮬레이션 하늘은 구름·시간대를 이미 반영했으니 안개만, + // 학습된 하늘·환경맵은 낮 사진이라 구운 하늘 가우시안과 같은 보정을 준다. 그다음 덮임 α 로 합친다. + vec3 lb = to_lin(bg_srgb); + if (W.p3.w < 0.5) { + float Lb = luma(lb); + lb = mix(lb, vec3(Lb), 0.55 * cloud) * (1.0 - 0.3 * cloud); + lb = mix(lb, vec3(0.42 + 0.25 * (1.0 - cloud)) * (0.55 + 0.45 * Lb), 0.85 * cloud); + lb = mix(vec3(0.002, 0.003, 0.008), lb * e * tint, smoothstep(0.05, 0.5, light)); + } + if (sigma > 0.0) lb = mix(fogc, lb, exp(-fog_optical_depth(W.campos.xyz, W.campos.xyz + dir * 400.0))); + lin = a * lin + (1.0 - a) * lb; + + // 6b. 볼류메트릭 전조등: 시선을 따라 걸으며 원뿔 안 산란광을 모은다 (밤 안개·비에서 빛줄기가 보인다). + // 표면 앞의 공기만 — 덮임이 반을 넘으면 표면까지, 아니면 60 m + int steps = int(W.p7.x); + if (sigma > 0.0 && W.hl_pos.w > 0.0 && steps > 0) { + float far = min(a > 0.5 && !sky ? range : 60.0, 60.0); + float dt = far / float(steps), vacc = 0.0, Tv = 1.0; + float jitter = hash(px + fract(W.p1.y) * 13.0); // 띠 무늬 대신 잡음 + for (int k = 0; k < steps; ++k) { + vec3 sp = W.campos.xyz + dir * (dt * (float(k) + jitter)); + float sg = sigma * exp(-max(sp.z - W.p1.w, 0.0) / max(W.p6.y, 1.0)); + vacc += headlight_irr(sp) * sg * Tv * dt; + Tv *= exp(-sg * dt); + } + lin += vacc * vec3(1.0, 0.96, 0.88) * 0.6; + } + + // 7. 강수 입자 (precip 레이어, 월드 공간·장면에 가려짐·안개 감쇠까지 이미 반영) + vec4 pr = imageLoad(uPrecip, ip); + lin = lin * (1.0 - pr.a) + pr.rgb; + + // 8. 렌즈 물방울 (비): 물방울 안은 뒤 장면이 뒤집혀 비친다 — 그 자리의 원본을 날씨 노출로만 근사 + float lens = W.p5.z * max(rain, 0.0); + if (lens > 0.0) { + vec4 dr = lens_drops(px, lens, W.p1.y); + if (dr.z > 0.0) { + ivec2 q = clamp(ivec2(px + dr.xy), ivec2(0), ivec2(W.size.xy) - 1); + vec3 seen = vec3(0.0); + for (int k = 0; k < 4; ++k) { // 물방울 상은 흐리다 + ivec2 o = ivec2((k & 1) * 4 - 2, (k >> 1) * 4 - 2); + seen += to_lin(composite(clamp(q + o, ivec2(0), ivec2(W.size.xy) - 1))); + } + seen = seen * 0.25; + seen = mix(seen, vec3(luma(seen)), 0.55 * cloud) * e * tint * (1.0 - 0.25 * rain); + // 물방울 상의 밝기는 주변과 같게 맞춘다 (원본에는 안개·조명이 없다) — 무늬만 뒤집혀 비친다 + seen *= (luma(lin) + 1e-3) / (luma(seen) + 1e-3); + seen = mix(seen, lin, 0.3); + seen *= 1.0 - 0.15 * dr.w; // 테두리는 조금 어둡고 + seen += 0.08 * e * smoothstep(0.85, 0.95, 1.0 - dr.w) * smoothstep(0.3, 0.6, dr.w); // 안쪽 고리 반짝임 + lin = mix(lin, seen, dr.z * 0.6); + } + } + + imageStore(uOut, ip, vec4(to_srgb(lin), 1.0)); +} diff --git a/src/vkgs/shaders/weather_common.glsl b/src/vkgs/shaders/weather_common.glsl new file mode 100644 index 0000000000000000000000000000000000000000..8d2c955b44346148cfc990cf465d16f086ad860c --- /dev/null +++ b/src/vkgs/shaders/weather_common.glsl @@ -0,0 +1,72 @@ +// 날씨 UBO 와 공통 함수 — weather.comp (후처리)와 precip.vert/frag (강수 입자)가 함께 쓴다. +// C++ 쪽은 viewer.cpp 의 WeatherUBO (같은 순서, std140). +#ifndef WEATHER_COMMON_GLSL +#define WEATHER_COMMON_GLSL +layout(std140, set = 0, binding = 1) uniform Weather { + vec4 p0; // 낮(0 밤 ~ 1 한낮), 구름, 비, 안개 + vec4 p1; // 노면 젖음, 시간 s, 켜짐(>0.5), 지면 z (씬 좌표) + vec4 campos; // 카메라 위치 (씬 좌표) + mat4 c2w; // 카메라 → 씬 회전 (열 = 카메라 x, y, z 축) + vec4 intr; // fx fy cx cy (렌더 해상도 기준) + vec4 size; // 폭, 높이 + vec4 hl_pos; // 전조등 위치 xyz, 세기 (0 = 끔) + vec4 hl_dir; // 전조등 방향 xyz (단위), 바깥 원뿔 cos + vec4 p2; // 눈, 쌓인 눈, -, 해 방위(도) + vec4 p3; // 구름 흐름 x, y, -, 시뮬레이션 하늘(1 이면 하늘은 sky_sim.frag 가 이미 날씨대로 그렸다) + vec4 p4; // 자차 속도 xyz (씬 m/s), 노출 시간 s (빗줄기 모션 블러) + vec4 p5; // 바람 xy (m/s), 렌즈 물방울 양, 물웅덩이 양 + vec4 p6; // 조리개 지름 m (가까운 입자 흐림), 안개 높이 척도 m, 입자 상자 반폭 xy·높이 m + vec4 p7; // 볼류메트릭 걸음 수, -, -, - +} gWeather[]; +#define W gWeather[kUboWeather] + +vec3 to_lin(vec3 c) { return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c)); } +vec3 to_srgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c)); +} +float luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); } +float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } + + +// 날씨에 따른 소광 계수 (1/m) — 안개 + 빗물 안개 + 눈. 노면 높이에서의 값 (높이 안개는 fog_optical_depth) +float fog_sigma() { + float fog = clamp(W.p0.w, 0.0, 1.0), rain = clamp(W.p0.z, 0.0, 1.0), snow = clamp(W.p2.x, 0.0, 1.0); + return 0.045 * fog * fog + 0.012 * rain + 0.012 * snow; +} +// 높이 안개: 밀도 σ(z) = σ0·exp(-(z - 지면)/H). 점 a 에서 b 까지의 광학 깊이 (해석적 적분) +float fog_optical_depth(vec3 a, vec3 b) { + float s0 = fog_sigma(); + if (s0 <= 0.0) return 0.0; + float H = max(W.p6.y, 1.0); + float ha = (a.z - W.p1.w) / H, hb = (b.z - W.p1.w) / H; + float L = length(b - a); + float dh = hb - ha; + // 밀도 ρ(h) = exp(−max(h, 0)) (지면 아래는 지면 밀도). 구간 평균 = (F(hb) − F(ha)) / dh, + // F(h) = h (h < 0), 1 − exp(−h) (h ≥ 0) — ρ 의 원시함수. 지면 아래 구간을 빠뜨리지 않는다 (검토 2026-10-05) + float avg; + if (abs(dh) < 1e-3) avg = exp(-max(0.5 * (ha + hb), 0.0)); + else { + float Fa = ha < 0.0 ? ha : 1.0 - exp(-ha); + float Fb = hb < 0.0 ? hb : 1.0 - exp(-hb); + avg = (Fb - Fa) / dh; + } + return s0 * L * max(avg, 0.0); +} +// 시간대 노출·색온도 (weather.comp 2단계와 같은 값) — 입자 조명에도 쓴다 +float day_exposure() { return mix(0.012, 1.0, pow(clamp(W.p0.x, 0.0, 1.0), 1.6)); } +vec3 day_tint() { + float light = clamp(W.p0.x, 0.0, 1.0); + vec3 t = mix(vec3(0.55, 0.68, 1.0), vec3(1.08, 0.88, 0.72), smoothstep(0.08, 0.45, light)); + return mix(t, vec3(1.0), smoothstep(0.55, 0.95, light)); +} +// 전조등 조도 (점 p) — 원뿔 × 거리 감쇠 +float headlight_irr(vec3 p) { + if (W.hl_pos.w <= 0.0) return 0.0; + vec3 v = p - W.hl_pos.xyz; + float d = length(v); + float c = dot(v / max(d, 1e-3), W.hl_dir.xyz); + float cone = smoothstep(W.hl_dir.w, mix(W.hl_dir.w, 1.0, 0.55), c); + return W.hl_pos.w * cone / (1.0 + 0.09 * d * d); +} +#endif diff --git a/src/vkgs/src/drive.hpp b/src/vkgs/src/drive.hpp new file mode 100644 index 0000000000000000000000000000000000000000..bc9f68e90162d684292efbfd7f974e9c0b8b8782 --- /dev/null +++ b/src/vkgs/src/drive.hpp @@ -0,0 +1,418 @@ +// closed-loop 주행 — 자차 상태 적분, 주행가능영역 판정, 로그 궤적 이탈. +// +// 지금은 키보드가 제어를 넣지만, 인터페이스(VehicleControl → step → 새 관측)는 +// VLA 모델이 같은 자리에 들어오도록 잡아 둔다 (roadmap 3단계). +// +// 좌표: 씬 원점 기준 전역(맵) 좌표, z 위. 자차 원점은 뒷바퀴 축 중앙 (nuScenes ego 와 같다). +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace vkgs { + +struct VehicleControl { + float throttle = 0; // 0..1 + float brake = 0; // 0..1 + float steer = 0; // -1(우) .. 1(좌) + bool handbrake = false; +}; + +// 차량 치수 (뒷바퀴 축 = 원점, x 앞). 기본은 nuScenes 자차 Renault Zoe. 자차 glTF 를 쓰면 모델의 바퀴 위치와 +// --ego-length 로 다시 잡는다 (viewer.cpp ego_geom_from_model). +// 기본값 = nuScenes 자차 Renault Zoe 실치수 (축거 2.588, 길이 4.08, 폭 1.73 → 앞 오버행 0.84, 뒤 0.65). +// nusim/sim/obstacles.py 의 ZOE_* 와 같아야 한다 (원래 경로 깎기). 예전 -1.0/3.2/±0.9 는 렌더(차 중심 1.4 m)와 +// 0.3 m 어긋났다 (2026-10-02 재검토). +struct VehicleGeom { + float wheelbase = 2.588f; + float rear = -0.65f, front = 3.43f; // 뒷바퀴 축 기준 차체 뒤끝·앞끝 (m) + float half_width = 0.87f; + float center() const { return 0.5f * (rear + front); } +}; + +// 키네마틱 자전거 모델. +struct Vehicle { + float x = 0, y = 0, z = 0, yaw = 0; // yaw: 전역 x 기준 반시계 + + float v = 0; // m/s, 후진은 음수 + float delta = 0; // 앞바퀴 조향각 rad + + VehicleGeom geom; + static constexpr float kMaxSteer = 0.61f; // 35° + static constexpr float kSteerRate = 1.2f; // rad/s — 핸들을 끝까지 돌리는 데 약 0.5초 + static constexpr float kBrake = 7.0f; // m/s² 풀 브레이크 + static constexpr float kMaxReverse = 3.0f; + static constexpr float kReverseAccel = 1.5f; // m/s² + // 씬마다 다르게 둘 값. 재구성된 구역이 경로를 따라 수십 m 뿐이라 80 km/h 면 1~2초 만에 + // 벗어난다 (사용자 시험 주행: 34초 동안 최대 232 m 이탈). 기본은 주차장·골목 속도. + float max_speed = 30.0f / 3.6f; // m/s + float accel = 2.0f; // m/s² 풀 스로틀 + + void step(const VehicleControl& c, float dt) { + // 조향: 속도가 높을수록 최대각을 줄인다 (키보드로는 미세 조향이 어려워서) + float max_d = kMaxSteer / (1.0f + std::abs(v) / 8.0f); + float target = std::clamp(c.steer, -1.0f, 1.0f) * max_d; + float dd = std::clamp(target - delta, -kSteerRate * dt, kSteerRate * dt); + delta += dd; + + // 종방향 (게임 관례): 아래 키 = 앞으로 가는 중이면 브레이크, 서 있거나 후진 중이면 후진 가속. + // 위 키 = 후진 중이면 브레이크, 아니면 전진 가속. + // 속도가 0 을 넘어 반대 부호가 되면 0 에서 한 번 멈춘다 — 다음 스텝에서 누른 키로 방향이 정해진다. + // (예전 식은 후진이 -0.1 m/s 에서 '후진에 대한 제동' 으로 바뀌어 5초를 눌러도 0.35 m 밖에 못 갔다) + bool fwd = v > 0.05f, back = v < -0.05f; + float a = 0; + if (c.throttle > 0) a += (back ? kBrake : accel) * c.throttle; + if (c.brake > 0) a += (fwd ? -kBrake : -kReverseAccel) * c.brake; + if (c.handbrake) a = fwd ? -kBrake * 1.2f : (back ? kBrake * 1.2f : 0.0f); + // 구름 저항 + 공기 저항 + if (c.throttle == 0 && c.brake == 0) a -= (v > 0 ? 1 : (v < 0 ? -1 : 0)) * (0.3f + 0.01f * v * v); + float v_new = v + a * dt; + if ((v > 0 && v_new < 0) || (v < 0 && v_new > 0)) v_new = 0; + v = std::clamp(v_new, -kMaxReverse, max_speed); + + // 위치/자세 (중점 적분) + float yaw_rate = v / geom.wheelbase * std::tan(delta); + float ym = yaw + 0.5f * yaw_rate * dt; + x += v * std::cos(ym) * dt; + y += v * std::sin(ym) * dt; + yaw += yaw_rate * dt; + } + + // 자차 박스 네 귀퉁이 (지면) + std::array, 4> footprint() const { + const float xs[2] = {geom.rear, geom.front}, ys[2] = {-geom.half_width, geom.half_width}; + float c = std::cos(yaw), s = std::sin(yaw); + std::array, 4> out{}; + int k = 0; + for (float px : xs) + for (float py : ys) out[k++] = {x + c * px - s * py, y + s * px + c * py}; + return out; + } +}; + +// nuScenes map expansion 의 주행가능영역 (구멍 포함 폴리곤들) +class Drivable { +public: + bool empty() const { return polys_.empty(); } + + void load(const std::string& path) { + std::ifstream f(path); + if (!f) throw std::runtime_error("주행가능영역 파일을 열 수 없음: " + path); + std::string line; + while (std::getline(f, line)) { + if (line.empty() || line[0] != 'P') continue; + std::istringstream ss(line.substr(1)); + int k; + ss >> k; + Poly p; + for (int i = 0; i < k; ++i) { + int n; + ss >> n; + std::vector ring(2 * n); + for (float& v : ring) ss >> v; + p.rings.push_back(std::move(ring)); + } + if (!p.rings.empty()) polys_.push_back(std::move(p)); + } + } + + bool contains(float x, float y) const { + for (const auto& p : polys_) { + if (!in_ring(p.rings[0], x, y)) continue; + bool in_hole = false; + for (size_t h = 1; h < p.rings.size() && !in_hole; ++h) in_hole = in_ring(p.rings[h], x, y); + if (!in_hole) return true; + } + return false; + } + +private: + struct Poly { + std::vector> rings; // [0] 외곽, 나머지 구멍. (x0,y0,x1,y1,...) + }; + static bool in_ring(const std::vector& r, float x, float y) { + bool in = false; + size_t n = r.size() / 2; + for (size_t i = 0, j = n - 1; i < n; j = i++) { + float xi = r[2 * i], yi = r[2 * i + 1], xj = r[2 * j], yj = r[2 * j + 1]; + if ((yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) in = !in; + } + return in; + } + std::vector polys_; +}; + +// 충돌용 장애물 지도 (nusim/sim/obstacles.py 가 정적 라이다로 만든 PGM). 255 = 차체 높이(지면 +0.25~2.5 m)에 +// 걸리는 것이 있는 칸 — 건물·기둥·벽·방호벽. 연석은 여기 없고 주행가능영역 경계로 따로 본다. +class ObstacleGrid { +public: + bool empty() const { return w_ == 0; } + size_t cells() const { + size_t n = 0; + for (uint8_t c : g_) n += c > 0; + return n; + } + + void load(const std::string& path) { + std::ifstream f(path, std::ios::binary); + if (!f) throw std::runtime_error("장애물 지도를 열 수 없음: " + path); + std::string magic, line; + f >> magic; + if (magic != "P5") throw std::runtime_error("장애물 지도가 PGM(P5) 이 아님: " + path); + bool have_geo = false; + auto next_token = [&]() { + std::string t; + while (f >> t) { + if (t[0] != '#') return t; + std::getline(f, line); // 주석 줄 나머지 + std::istringstream ss(line); + std::string tag; + if (ss >> tag && tag == "nusim" && ss >> tag && tag == "obstacles" && ss >> x0_ >> y0_ >> res_) + have_geo = true; + } + throw std::runtime_error("장애물 지도 헤더가 잘림: " + path); + }; + w_ = std::stoi(next_token()); + h_ = std::stoi(next_token()); + int maxv = std::stoi(next_token()); + if (!have_geo || maxv != 255) throw std::runtime_error("장애물 지도 헤더에 '# nusim obstacles x0 y0 res' 가 없음: " + path); + f.get(); // 헤더 끝 공백 하나 + g_.resize(size_t(w_) * h_); + f.read(reinterpret_cast(g_.data()), std::streamsize(g_.size())); + if (!f) throw std::runtime_error("장애물 지도 데이터가 잘림: " + path); + } + + bool occupied(float x, float y) const { + int ix = int(std::floor((x - x0_) / res_)), iy = int(std::floor((y - y0_) / res_)); + if (ix < 0 || iy < 0 || ix >= w_ || iy >= h_) return false; // 지도 밖은 모른다 — 막지 않는다 + return g_[size_t(iy) * w_ + ix] != 0; + } + +private: + int w_ = 0, h_ = 0; + float x0_ = 0, y0_ = 0, res_ = 0.2f; + std::vector g_; +}; + +enum class Contact { None = 0, Obstacle = 1, Curb = 2, Vehicle = 3 }; + +struct ContactInfo { + Contact kind = Contact::None; // 우선순위: 다른 차량 > 장애물 > 연석 + int obstacle = 0, curb = 0, vehicle = 0; // 닿은 샘플 수 + int total() const { return obstacle + curb + vehicle; } + // 스쳐 닿음(graze 샘플 이하)을 뺀 깊이 — 장애물 지도의 외톨이 칸·화단 가장자리의 한 칸 돌출이 차체 옆을 스치기만 해도 + // 막히고, 앞뒤 어느 쪽으로도 그 칸을 벗어나지 못해 갇혔다 (scene-0402, 2026-10-09). 벽·기둥에 박으면 차체 한 줄(약 13점)이 + // 0.1 m 안에 닿으므로 여전히 막힌다. + int depth(int graze) const { return std::max(0, total() - graze); } +}; + +// 다른 차량(장면 객체)의 바닥 사각형 — 씬 로컬 좌표, 박스 중심, 방위(전역 x 기준 반시계 +), 반 길이·반 폭. +// 장애물 지도(정적 라이다)는 차량을 지운 배경이라 장면 객체와의 충돌은 이걸로 따로 본다. +struct Footprint { + float x = 0, y = 0, yaw = 0, half_len = 0, half_wid = 0; + bool contains(float px, float py, float shrink = 0) const { + float dx = px - x, dy = py - y, c = std::cos(yaw), s = std::sin(yaw); + float lx = c * dx + s * dy, ly = -s * dx + c * dy; // 박스 좌표로 (Rᵀ) + return std::abs(lx) <= half_len - shrink && std::abs(ly) <= half_wid - shrink; + } +}; + +// 차체가 무엇에 몇 점 닿았나. 장애물은 차체 사각형 전체(가장자리 + 내부)를 0.1 m 간격으로 보되, 둘레를 obst_tol +// 만큼 줄여서 본다 — 지도 칸(0.2 m)이 통째로 막힘이 되고 라이다 누적의 포즈 잡음으로 물체가 10~20 cm 부풀어 +// 보여서, 그대로 보면 실제보다 일찍 막힌다 (사용자 지적: '물리 박스가 너무 크다'). +// 연석은 차체 둘레 점이 주행가능영역 밖인지로 본다 — 폴리곤 이음매에서 깜빡이지 않게 curb_tol 만큼 줄여서. +// 개수를 세는 이유: 이미 닿아 있는 자세(출발점 등)에서 '빠져나가는 움직임' 은 허용하고 '더 파고드는 움직임' 은 +// 막으려고 (예전엔 닿아 있는 동안 판정을 통째로 껐다 — 벽을 통과할 수 있었다). +// others: 다른 차량의 바닥 사각형 — 자차 차체 샘플이 그 안(obst_tol 만큼 줄인)에 들어가면 vehicle 로 센다. +inline ContactInfo vehicle_contact(const Vehicle& car, const ObstacleGrid& obst, const Drivable& road, bool curb, + float curb_tol = 0.15f, float obst_tol = 0.1f, + const std::vector* others = nullptr) { + ContactInfo ci; + float c = std::cos(car.yaw), s = std::sin(car.yaw); + const VehicleGeom& g = car.geom; + auto world = [&](float px, float py) { return std::array{car.x + c * px - s * py, car.y + s * px + c * py}; }; + // 가까운 차량만 (자차 중심에서 두 차 대각선 합 안) + std::vector near; + if (others) { + float rc = std::hypot(std::max(-g.rear, g.front), g.half_width); + for (const auto& f : *others) { + float ctr = g.center(), cx = car.x + c * ctr, cy = car.y + s * ctr; + if (std::hypot(f.x - cx, f.y - cy) < rc + std::hypot(f.half_len, f.half_wid)) near.push_back(&f); + } + } + if (!obst.empty() || !near.empty()) { + const float step = 0.1f; + for (float px = g.rear + obst_tol; px <= g.front - obst_tol + 1e-4f; px += step) + for (float py = -g.half_width + obst_tol; py <= g.half_width - obst_tol + 1e-4f; py += step) { + auto p = world(px, py); + if (!obst.empty()) ci.obstacle += obst.occupied(p[0], p[1]); + for (const Footprint* f : near) + if (f->contains(p[0], p[1])) { ++ci.vehicle; break; } + } + } + if (curb && !road.empty()) { + float r = g.rear + curb_tol, f = g.front - curb_tol, w = g.half_width - curb_tol; + const float xs[3] = {r, 0.5f * (r + f), f}, ys[3] = {-w, 0.0f, w}; + for (float px : xs) + for (float py : ys) { + if (px == xs[1] && py == 0.0f) continue; + auto p = world(px, py); + ci.curb += !road.contains(p[0], p[1]); + } + } + ci.kind = ci.vehicle ? Contact::Vehicle : ci.obstacle ? Contact::Obstacle : (ci.curb ? Contact::Curb : Contact::None); + return ci; +} + +// 닿은 깊이가 늘어난 스텝이라도 '빠져나가는 중' 이면 허용한다: 그 자세에서 같은 방향(전진/후진)·같은 조향으로 +// probe 만큼 더 가 본 자세의 깊이(ContactInfo::depth)가 지금(now_depth) 이하면 참. 점 수만 비교하면 비스듬한 연석·화단에서 후진할 때 +// 뒤 모서리 샘플이 앞 샘플보다 먼저 새 칸에 들어가 +1 이 되고, 매 스텝 막혀 속도가 0 으로 돌아가 앞뒤로 못 움직였다 +// (scene-0402 시험 주행, 2026-10-09). 파고드는 움직임은 0.3 m 앞에서 닿은 점이 더 많으므로 여전히 막힌다. +inline bool escaping(const Vehicle& from, float dir, int now_depth, const ObstacleGrid& obst, const Drivable& road, + bool curb, float curb_tol, float obst_tol, int graze = 0, const std::vector* others = nullptr, + float probe = 0.3f) { + if (dir == 0) return false; + Vehicle p = from; + const float ds = 0.05f; + for (float s = 0; s < probe - 1e-4f; s += ds) { + p.x += dir * ds * std::cos(p.yaw); + p.y += dir * ds * std::sin(p.yaw); + p.yaw += dir * ds * std::tan(p.delta) / p.geom.wheelbase; + } + return vehicle_contact(p, obst, road, curb, curb_tol, obst_tol, others).depth(graze) <= now_depth; +} + +// 충돌 판정 조정값 (configs/sim.json collision) — 창 운전 루프와 closed-loop 서버(--serve)가 같은 값으로 판정한다. +struct ContactParams { + bool curb = true; + float curb_tol = 0.15f, obst_tol = 0.1f; + int graze = 2; +}; + +struct SubstepResult { + bool blocked = false; // 더 파고드는 움직임이라 직전 자세로 되돌리고 멈췄다 + Contact kind = Contact::None; // 이 스텝에서 닿은 것 (막힘 포함) + ContactInfo hit; // 적분한 자세에서 닿은 샘플 수 +}; + +// 물리 한 스텝 (h 초, 0.01 s 이하로 쪼개 부른다): 차량 적분 → 차체 접촉 → 더 파고들면 막는다. +// cur = 지금 자세에서 닿은 샘플 수 (막히지 않았으면 새 자세 것으로 바뀐다). 창 운전 루프(viewer.cpp)와 +// closed-loop 서버가 같이 쓴다 — 둘의 충돌 거동이 갈라지지 않게. +inline SubstepResult step_with_contact(Vehicle& car, const VehicleControl& ctl, float h, ContactInfo& cur, + const ObstacleGrid& obst, const Drivable& road, const ContactParams& p, + const std::vector* others = nullptr) { + SubstepResult r; + Vehicle prev = car; + car.step(ctl, h); + r.hit = vehicle_contact(car, obst, road, p.curb, p.curb_tol, p.obst_tol, others); + bool deeper = r.hit.depth(p.graze) > cur.depth(p.graze) && + !escaping(car, car.v > 0 ? 1.0f : car.v < 0 ? -1.0f : 0.0f, cur.depth(p.graze), obst, road, p.curb, + p.curb_tol, p.obst_tol, p.graze, others); + if (deeper) { // 더 파고드는 움직임만 막는다 (빠져나가는 중이면 샘플 양자화로 잠깐 늘어도 허용) + // 막혔다: 위치·방향은 직전으로, 속도는 0. 조향은 살려 둔다 (핸들을 돌려 빠져나갈 수 있게) + float d = car.delta; + car = prev; + car.delta = d; + car.v = 0; + r.blocked = true; + } else { + cur = r.hit; + } + r.kind = r.hit.kind; + return r; +} + +// 가속도(m/s²)·앞바퀴 조향각(rad) 명령 → 페달·핸들 (Vehicle::step 의 역). LQR 같은 제어기가 물리량으로 명령할 때. +// 가속 > 0: 스로틀 = a / 최대 가속 (후진 중이면 제동 세기 기준) +// 가속 < 0: 전진 중이면 브레이크 = −a / 풀 브레이크. 서 있거나 후진 중이면 페달을 떼고 선다 — 브레이크 키는 +// 정지 후 후진이 되므로 (게임 관례) '감속' 명령이 후진으로 바뀌지 않게. +// 전진 중 a ≈ 0 은 아주 작은 스로틀로 — 페달을 다 떼면 구름 저항(−0.3 m/s²)이 걸린다. +// 조향: Vehicle::step 이 속도에 따라 줄이는 최대각으로 나눈다 (조향 속도 제한은 step 이 건다). +inline VehicleControl control_for(const Vehicle& car, float accel, float delta) { + VehicleControl c; + float max_d = Vehicle::kMaxSteer / (1.0f + std::abs(car.v) / 8.0f); + c.steer = std::clamp(delta / max_d, -1.0f, 1.0f); + bool fwd = car.v > 0.05f, back = car.v < -0.05f; + if (accel > 0) c.throttle = std::clamp(accel / (back ? Vehicle::kBrake : car.accel), 0.0f, 1.0f); + else if (accel < 0 && fwd) c.brake = std::clamp(-accel / Vehicle::kBrake, 0.0f, 1.0f); + if (fwd && c.throttle == 0 && c.brake == 0) c.throttle = 1e-4f; + return c; +} + +// 0/1 신호를 일정 시간 지속돼야 바뀌게 한다. 도로 경계를 따라 달리면 차 모서리가 선을 +// 넘나들어 이탈/복귀가 0.1초 간격으로 깜빡인다 (사용자 시험 주행에서 실측). +struct Debounce { + bool state = true; + float hold = 0.3f; // s + float t = 0; + bool update(bool raw, float dt) { + if (raw == state) { + t = 0; + } else if ((t += dt) >= hold) { + state = raw; + t = 0; + } + return state; + } +}; + +// 로그(원래 주행) 궤적까지의 최소 거리 — 경로 이탈 지표 +inline float distance_to_polyline(const std::vector>& pl, float x, float y) { + float best = 1e30f; + for (size_t i = 0; i + 1 < pl.size(); ++i) { + float ax = pl[i][0], ay = pl[i][1], bx = pl[i + 1][0], by = pl[i + 1][1]; + float dx = bx - ax, dy = by - ay; + float l2 = dx * dx + dy * dy; + float t = l2 > 0 ? std::clamp(((x - ax) * dx + (y - ay) * dy) / l2, 0.0f, 1.0f) : 0.0f; + float px = ax + t * dx - x, py = ay + t * dy - y; + best = std::min(best, px * px + py * py); + } + return std::sqrt(best); +} + +// 자동 주행 (뷰어 T · --autopilot): 기록 궤적 path 를 pure pursuit 로 따라가고 목표 속도를 P 제어로 맞춘다. +// idx = 지난번 가장 가까운 궤적 점 (앞쪽으로만 찾아 되돌아가지 않는다). 끝에 닿으면 멈춘다. +// 조향 명령은 step() 의 속도별 최대 조향각으로 나눈 -1..1 — 키보드와 같은 경로로 들어간다. +struct AutopilotParams { + float target_v = 20.0f / 3.6f; // m/s + float lookahead_min = 5.0f, lookahead_per_v = 0.8f; // m, s + float speed_gain = 0.6f; // (목표 − 현재 m/s) × 이 값 = 스로틀 · 브레이크 + float stop_before_end = 6.0f; // m — 궤적 끝 이만큼 앞에서 멈춘다 +}; +inline VehicleControl follow_path(const Vehicle& car, const std::vector>& path, size_t& idx, + const AutopilotParams& p = {}) { + VehicleControl c; + if (path.size() < 2) { c.brake = 1; return c; } + size_t best = idx; + float bd = 1e30f; + for (size_t i = idx; i < std::min(path.size(), idx + 80); ++i) { + float d = std::hypot(path[i][0] - car.x, path[i][1] - car.y); + if (d < bd) bd = d, best = i; + } + idx = best; + float L = std::max(p.lookahead_min, p.lookahead_per_v * std::abs(car.v)); + float left = 0; // 가장 가까운 점부터 궤적 끝까지 남은 길이 + for (size_t i = best; i + 1 < path.size(); ++i) left += std::hypot(path[i + 1][0] - path[i][0], path[i + 1][1] - path[i][1]); + size_t t = best; + while (t + 1 < path.size() && std::hypot(path[t][0] - car.x, path[t][1] - car.y) < L) ++t; + float dx = path[t][0] - car.x, dy = path[t][1] - car.y; + float lx = std::cos(car.yaw) * dx + std::sin(car.yaw) * dy, ly = -std::sin(car.yaw) * dx + std::cos(car.yaw) * dy; + float ld = std::max(std::hypot(lx, ly), 1e-3f); + float delta = std::atan(2.0f * car.geom.wheelbase * ly / (ld * ld)); // pure pursuit 곡률 → 조향각 + float max_d = Vehicle::kMaxSteer / (1.0f + std::abs(car.v) / 8.0f); + c.steer = std::clamp(delta / max_d, -1.0f, 1.0f); + float want = left > p.stop_before_end ? std::min(p.target_v, std::sqrt(2.0f * 2.0f * (left - p.stop_before_end)) + 0.5f) : 0.0f; + float e = want - car.v; + if (want <= 0.0f) c.brake = car.v > 0.05f ? 1.0f : 0.0f; + else if (e > 0) c.throttle = std::clamp(e * p.speed_gain, 0.0f, 1.0f); + else c.brake = std::clamp(-e * p.speed_gain * 0.5f, 0.0f, 1.0f); + return c; +} + +} // namespace vkgs diff --git a/src/vkgs/src/gltf.cpp b/src/vkgs/src/gltf.cpp new file mode 100644 index 0000000000000000000000000000000000000000..78d74888f6cb744e4dd341dce61bac49ebb681e6 --- /dev/null +++ b/src/vkgs/src/gltf.cpp @@ -0,0 +1,279 @@ +#define CGLTF_IMPLEMENTATION +#include "cgltf.h" + +#define STB_IMAGE_IMPLEMENTATION +#define STBI_ONLY_PNG +#define STBI_ONLY_JPEG +#include "stb_image.h" + +#include "gltf.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace vkgs { +namespace { + +void set_identity_xform(float m[6]) { + m[0] = 1; m[1] = 0; m[2] = 0; + m[3] = 0; m[4] = 1; m[5] = 0; +} + +// KHR_texture_transform: M = T · R · S (스펙의 행렬 정의 그대로) +void uv_transform(const cgltf_texture_view& v, float m[6], int& texcoord) { + set_identity_xform(m); + texcoord = v.texcoord; + if (!v.has_transform) return; + const cgltf_texture_transform& t = v.transform; + float c = std::cos(t.rotation), s = std::sin(t.rotation); + m[0] = c * t.scale[0]; m[1] = s * t.scale[1]; m[2] = t.offset[0]; + m[3] = -s * t.scale[0]; m[4] = c * t.scale[1]; m[5] = t.offset[1]; + if (t.has_texcoord) texcoord = t.texcoord; +} + +} // namespace + +GltfModel load_gltf(const std::string& path) { + cgltf_options opt{}; + cgltf_data* data = nullptr; + if (cgltf_parse_file(&opt, path.c_str(), &data) != cgltf_result_success) + throw std::runtime_error("glTF 파싱 실패: " + path); + struct Guard { + cgltf_data* d; + ~Guard() { cgltf_free(d); } + } guard{data}; + if (cgltf_load_buffers(&opt, data, path.c_str()) != cgltf_result_success) + throw std::runtime_error("glTF 버퍼 로드 실패: " + path); + + GltfModel model; + + // --- 텍스처: (이미지, sRGB 여부) 마다 하나. 같은 이미지를 색/데이터 양쪽에 쓰는 경우를 대비 + std::map, int> tex_index; + auto texture_of = [&](const cgltf_texture_view& v, bool srgb) -> int { + if (!v.texture || !v.texture->image) return -1; + const cgltf_image* img = v.texture->image; + auto key = std::make_pair(img, srgb); + if (auto it = tex_index.find(key); it != tex_index.end()) return it->second; + int w = 0, h = 0, n = 0; + stbi_uc* px = nullptr; + if (img->buffer_view) { + const uint8_t* p = cgltf_buffer_view_data(img->buffer_view); + px = stbi_load_from_memory(p, int(img->buffer_view->size), &w, &h, &n, 4); + } else if (img->uri) { + std::string dir = path.substr(0, path.find_last_of('/') + 1); + px = stbi_load((dir + img->uri).c_str(), &w, &h, &n, 4); + } + if (!px) throw std::runtime_error("glTF 텍스처 디코드 실패 (" + std::string(img->name ? img->name : "?") + ")"); + GltfTexture t; + t.width = w; + t.height = h; + t.srgb = srgb; + t.rgba.assign(px, px + size_t(w) * h * 4); + stbi_image_free(px); + model.textures.push_back(std::move(t)); + int id = int(model.textures.size()) - 1; + tex_index[key] = id; + return id; + }; + + // --- 재질 + for (cgltf_size i = 0; i < data->materials_count; ++i) { + const cgltf_material& m = data->materials[i]; + GltfMaterial g; + for (auto& x : g.uv_xform) set_identity_xform(x); + if (m.has_pbr_metallic_roughness) { + const auto& p = m.pbr_metallic_roughness; + std::memcpy(g.base, p.base_color_factor, sizeof(g.base)); + g.metallic = p.metallic_factor; + g.roughness = p.roughness_factor; + g.tex[kBase] = texture_of(p.base_color_texture, true); + uv_transform(p.base_color_texture, g.uv_xform[kBase], g.texcoord[kBase]); + g.tex[kMetalRough] = texture_of(p.metallic_roughness_texture, false); + uv_transform(p.metallic_roughness_texture, g.uv_xform[kMetalRough], g.texcoord[kMetalRough]); + } + g.tex[kNormal] = texture_of(m.normal_texture, false); + uv_transform(m.normal_texture, g.uv_xform[kNormal], g.texcoord[kNormal]); + g.normal_scale = m.normal_texture.texture ? m.normal_texture.scale : 1.0f; + g.tex[kOcclusion] = texture_of(m.occlusion_texture, false); + uv_transform(m.occlusion_texture, g.uv_xform[kOcclusion], g.texcoord[kOcclusion]); + g.occlusion_strength = m.occlusion_texture.texture ? m.occlusion_texture.scale : 1.0f; + g.tex[kEmissive] = texture_of(m.emissive_texture, true); + uv_transform(m.emissive_texture, g.uv_xform[kEmissive], g.texcoord[kEmissive]); + std::memcpy(g.emissive, m.emissive_factor, sizeof(g.emissive)); + if (m.has_emissive_strength) g.emissive_strength = m.emissive_strength.emissive_strength; + g.alpha_mode = m.alpha_mode == cgltf_alpha_mode_mask ? 1 : (m.alpha_mode == cgltf_alpha_mode_blend ? 2 : 0); + g.alpha_cutoff = m.alpha_cutoff; + g.double_sided = m.double_sided; + if (m.has_transmission) g.transmission = m.transmission.transmission_factor; + if (m.has_clearcoat) { + g.clearcoat = m.clearcoat.clearcoat_factor; + g.clearcoat_roughness = m.clearcoat.clearcoat_roughness_factor; + } + model.materials.push_back(g); + } + + // --- 노드 트리 → 월드 변환을 구운 프리미티브 + // 바퀴 노드: 자신 또는 조상 중 이름에 'wheel' 이 든 가장 바깥 노드. 'WheelFrontL' 아래 'WheelFrontLRim', + // 'WheelFrontLBrakeDisc' 가 있으면 전부 WheelFrontL 하나로 묶는다. 'steering'(핸들), 'wheels'(네 바퀴를 + // 담은 그룹) 는 바퀴 하나가 아니므로 제외. + std::vector wheel_nodes; + auto wheel_of = [&](const cgltf_node* n) -> int { + const cgltf_node* top = nullptr; + for (; n; n = n->parent) { + if (!n->name) continue; + std::string nm = n->name; + for (char& ch : nm) ch = char(std::tolower(static_cast(ch))); + if (nm.find("wheel") == std::string::npos || nm.find("wheels") != std::string::npos || + nm.find("steering") != std::string::npos) + continue; + top = n; + } + if (!top) return -1; + for (size_t k = 0; k < wheel_nodes.size(); ++k) + if (wheel_nodes[k] == top) return int(k); + wheel_nodes.push_back(top); + return int(wheel_nodes.size()) - 1; + }; + auto add_mesh = [&](const cgltf_node* node) { + int part = wheel_of(node); + float W[16]; + cgltf_node_transform_world(node, W); // 열 우선 + // 법선 행렬 = (W 의 3x3)^-T + float a = W[0], b = W[4], c = W[8], d = W[1], e = W[5], f = W[9], g = W[2], h = W[6], k = W[10]; + float det = a * (e * k - f * h) - b * (d * k - f * g) + c * (d * h - e * g); + float N[9] = {(e * k - f * h) / det, -(d * k - f * g) / det, (d * h - e * g) / det, + -(b * k - c * h) / det, (a * k - c * g) / det, -(a * h - b * g) / det, + (b * f - c * e) / det, -(a * f - c * d) / det, (a * e - b * d) / det}; + bool flip = det < 0; // 음수 스케일이면 감김이 뒤집힌다 + for (cgltf_size pi = 0; pi < node->mesh->primitives_count; ++pi) { + const cgltf_primitive& prim = node->mesh->primitives[pi]; + if (prim.type != cgltf_primitive_type_triangles) continue; + const cgltf_accessor *pos = nullptr, *nrm = nullptr, *uv0 = nullptr, *uv1 = nullptr, *tan = nullptr; + for (cgltf_size ai = 0; ai < prim.attributes_count; ++ai) { + const auto& at = prim.attributes[ai]; + if (at.type == cgltf_attribute_type_position) pos = at.data; + else if (at.type == cgltf_attribute_type_normal) nrm = at.data; + else if (at.type == cgltf_attribute_type_tangent) tan = at.data; + else if (at.type == cgltf_attribute_type_texcoord && at.index == 0) uv0 = at.data; + else if (at.type == cgltf_attribute_type_texcoord && at.index == 1) uv1 = at.data; + } + if (!pos) continue; + uint32_t base = uint32_t(model.vertices.size()); + for (cgltf_size vi = 0; vi < pos->count; ++vi) { + GltfVertex v{}; + float p[3] = {0, 0, 0}, n[3] = {0, 0, 1}, t4[4] = {0, 0, 0, 0}; + cgltf_accessor_read_float(pos, vi, p, 3); + if (nrm) cgltf_accessor_read_float(nrm, vi, n, 3); + if (uv0) cgltf_accessor_read_float(uv0, vi, v.uv0, 2); + if (uv1) cgltf_accessor_read_float(uv1, vi, v.uv1, 2); + if (tan) cgltf_accessor_read_float(tan, vi, t4, 4); + for (int r = 0; r < 3; ++r) { + v.pos[r] = W[r] * p[0] + W[4 + r] * p[1] + W[8 + r] * p[2] + W[12 + r]; + v.nrm[r] = N[r * 3 + 0] * n[0] + N[r * 3 + 1] * n[1] + N[r * 3 + 2] * n[2]; + v.tan[r] = W[r] * t4[0] + W[4 + r] * t4[1] + W[8 + r] * t4[2]; + model.bmin[r] = std::min(model.bmin[r], v.pos[r]); + model.bmax[r] = std::max(model.bmax[r], v.pos[r]); + } + float nl = std::sqrt(v.nrm[0] * v.nrm[0] + v.nrm[1] * v.nrm[1] + v.nrm[2] * v.nrm[2]); + for (float& x : v.nrm) x /= (nl > 0 ? nl : 1); + v.tan[3] = tan ? t4[3] * (flip ? -1.0f : 1.0f) : 0.0f; + model.vertices.push_back(v); + } + GltfPrimitive gp; + gp.first_index = uint32_t(model.indices.size()); + gp.vertex_offset = 0; + if (prim.indices) { + for (cgltf_size ii = 0; ii < prim.indices->count; ii += 3) { + uint32_t i0 = uint32_t(cgltf_accessor_read_index(prim.indices, ii)); + uint32_t i1 = uint32_t(cgltf_accessor_read_index(prim.indices, ii + 1)); + uint32_t i2 = uint32_t(cgltf_accessor_read_index(prim.indices, ii + 2)); + if (flip) std::swap(i1, i2); + model.indices.insert(model.indices.end(), {base + i0, base + i1, base + i2}); + } + } else { + for (uint32_t ii = 0; ii + 2 < pos->count; ii += 3) + model.indices.insert(model.indices.end(), {base + ii, base + ii + (flip ? 2 : 1), base + ii + (flip ? 1 : 2)}); + } + gp.index_count = uint32_t(model.indices.size()) - gp.first_index; + gp.material = prim.material ? int(prim.material - data->materials) : -1; + gp.part = part; + if (part >= 0 && node->name) { + std::string nm = node->name; + for (char& ch : nm) ch = char(std::tolower(static_cast(ch))); + gp.spins = nm.find("brakepad") == std::string::npos && nm.find("caliper") == std::string::npos; + } + model.prims.push_back(gp); + } + }; + std::vector stack; + const cgltf_scene* scene = data->scene ? data->scene : (data->scenes_count ? &data->scenes[0] : nullptr); + if (scene) + for (cgltf_size i = 0; i < scene->nodes_count; ++i) stack.push_back(scene->nodes[i]); + else + for (cgltf_size i = 0; i < data->nodes_count; ++i) + if (!data->nodes[i].parent) stack.push_back(&data->nodes[i]); + while (!stack.empty()) { + const cgltf_node* n = stack.back(); + stack.pop_back(); + if (n->mesh) add_mesh(n); + for (cgltf_size i = 0; i < n->children_count; ++i) stack.push_back(n->children[i]); + } + if (model.prims.empty()) throw std::runtime_error("glTF 에 그릴 삼각형 메시가 없음: " + path); + + // 바퀴마다 정점 경계 상자 → 회전 중심·반지름. 앞바퀴 = 길이축(Z) 중심보다 앞. + model.wheels.resize(wheel_nodes.size()); + std::vector> wb(wheel_nodes.size(), {1e30f, 1e30f, 1e30f, -1e30f, -1e30f, -1e30f}); + for (const auto& gp : model.prims) { + if (gp.part < 0) continue; + auto& b = wb[gp.part]; + for (uint32_t i = gp.first_index; i < gp.first_index + gp.index_count; ++i) { + const float* p = model.vertices[model.indices[i]].pos; + for (int r = 0; r < 3; ++r) { + b[r] = std::min(b[r], p[r]); + b[3 + r] = std::max(b[3 + r], p[r]); + } + } + } + // 축 방향: 바퀴 정점의 수평(X-Z) 분산이 가장 작은 방향 (원판·타이어는 축 방향으로 얇다) + std::vector> cov(wheel_nodes.size(), {0, 0, 0, 0, 0}); // n, Σx, Σz, Σxx, Σzz … 아래 Σxz + std::vector cxz(wheel_nodes.size(), 0); + for (const auto& gp : model.prims) { + if (gp.part < 0 || !gp.spins) continue; + auto& c = cov[gp.part]; + for (uint32_t i = gp.first_index; i < gp.first_index + gp.index_count; ++i) { + const float* p = model.vertices[model.indices[i]].pos; + c[0] += 1; c[1] += p[0]; c[2] += p[2]; c[3] += double(p[0]) * p[0]; c[4] += double(p[2]) * p[2]; + cxz[gp.part] += double(p[0]) * p[2]; + } + } + float zmid = 0.5f * (model.bmin[2] + model.bmax[2]); + for (size_t k = 0; k < wheel_nodes.size(); ++k) { + GltfWheel& w = model.wheels[k]; + w.name = wheel_nodes[k]->name ? wheel_nodes[k]->name : ""; + for (int r = 0; r < 3; ++r) w.center[r] = 0.5f * (wb[k][r] + wb[k][3 + r]); + w.radius = 0.5f * (wb[k][4] - wb[k][1]); // 높이(Y) = 지름. 앞뒤 크기는 구워진 조향각만큼 부풀어 있다 + w.front = w.center[2] > zmid; + const auto& c = cov[k]; + if (c[0] > 2) { + double mx = c[1] / c[0], mz = c[2] / c[0]; + double sxx = c[3] / c[0] - mx * mx, szz = c[4] / c[0] - mz * mz, sxz = cxz[k] / c[0] - mx * mz; + // 2x2 공분산의 작은 고유값 방향 = 축. 주축 각 φ = ½·atan2(2sxz, sxx − szz) 는 큰 고유값 방향이다 + double major = 0.5 * std::atan2(2 * sxz, sxx - szz); + double ax = -std::sin(major), az = std::cos(major); // major 에 수직 + if (ax < 0) { ax = -ax; az = -az; } + w.toe = float(std::atan2(az, ax)); // 축이 X 축에서 +Z 쪽으로 기운 각 + } + if (std::getenv("VKGS_DEBUG_WHEELS")) + std::fprintf(stderr, "wheel %-12s center (%.3f %.3f %.3f) r %.3f toe %.1f deg\n", w.name.c_str(), w.center[0], + w.center[1], w.center[2], w.radius, w.toe * 57.29578f); + } + return model; +} + +} // namespace vkgs diff --git a/src/vkgs/src/gltf.hpp b/src/vkgs/src/gltf.hpp new file mode 100644 index 0000000000000000000000000000000000000000..0995e11ade14f887dfac28a96c6b859bd2c93767 --- /dev/null +++ b/src/vkgs/src/gltf.hpp @@ -0,0 +1,83 @@ +// glTF 2.0 (.glb/.gltf) 로더 — 시뮬레이터의 메시 차량 (다른 차들). +// +// 노드 트리의 월드 변환을 정점에 구워 넣어 '평평한' 프리미티브 목록으로 만든다 (glTF 애니메이션·스키닝은 +// 다루지 않는다). 다만 바퀴 노드는 프리미티브에 표시해 두어 렌더러가 굴리고 조향할 수 있게 한다. +// 재질은 metallic-roughness 코어 + 시뮬레이션 화질에 효과가 큰 확장 몇 개: KHR_texture_transform (UV 가 틀리면 텍스처가 통째로 어긋난다), KHR_materials_clearcoat +// (차 도장의 코팅층), KHR_materials_transmission (유리 — 반투명으로 근사), KHR_materials_emissive_strength. +#pragma once + +#include +#include +#include +#include + +namespace vkgs { + +struct GltfVertex { // 56 B — shaders/pbr_gltf.vert 의 입력과 같은 순서 + float pos[3]; + float nrm[3]; + float uv0[2]; + float uv1[2]; + float tan[4]; // w = 0 이면 탄젠트 없음 → 셰이더가 화면 미분으로 TBN 을 만든다 +}; +static_assert(sizeof(GltfVertex) == 56, "정점 레이아웃"); + +struct GltfTexture { + int width = 0, height = 0; + std::vector rgba; + bool srgb = false; // 기본색·발광은 sRGB, 나머지(법선·금속/거칠기·차폐)는 선형 +}; + +enum TexSlot { kBase = 0, kMetalRough, kNormal, kOcclusion, kEmissive, kSlots }; + +struct GltfMaterial { + float base[4] = {1, 1, 1, 1}; + float metallic = 1, roughness = 1; + float emissive[3] = {0, 0, 0}; + float emissive_strength = 1; + float normal_scale = 1, occlusion_strength = 1; + float alpha_cutoff = 0.5f; + int alpha_mode = 0; // 0 OPAQUE, 1 MASK, 2 BLEND + float transmission = 0; + float clearcoat = 0, clearcoat_roughness = 0; + bool double_sided = false; + int tex[kSlots] = {-1, -1, -1, -1, -1}; + int texcoord[kSlots] = {0, 0, 0, 0, 0}; + float uv_xform[kSlots][6] = {}; // KHR_texture_transform: uv' = [a b c; d e f]·[u v 1] + bool translucent() const { return alpha_mode == 2 || transmission > 0.0f; } +}; + +struct GltfPrimitive { + uint32_t first_index = 0, index_count = 0; + int32_t vertex_offset = 0; + int material = -1; + int part = -1; // -1 = 차체, k >= 0 = GltfModel::wheels[k] 에 속한 바퀴 (자차 바퀴 애니메이션) + bool spins = true; // 바퀴 부품 중 캘리퍼('BrakePad', 'Caliper')는 조향만 따라가고 구르지 않는다 +}; + +// 'Wheel' 이 이름에 든 노드(핸들 'Steering…' 은 제외) 아래 메시 = 바퀴 하나. 정점은 노드 트리 변환이 구워진 +// 모델 좌표라 회전 중심은 정점 경계 상자의 중심으로 잡는다. +struct GltfWheel { + std::string name; + float center[3] = {0, 0, 0}; + float radius = 0; // 모델 단위 — 높이(Y) 크기의 절반 + bool front = false; // 모델 길이축(+Z = 앞) 중심보다 앞이면 조향 바퀴 + // 모델에 구워진 조향각 (rad, Y 축). 전시용 모델은 앞바퀴를 꺾어 둔 채로 저장된 경우가 많다 (CarConcept: 약 30°). + // 그대로 X 축으로 굴리면 바퀴가 원을 그리지 않고 비틀거린다 → 먼저 이만큼 되돌려 곧게 편다. + float toe = 0; +}; + +struct GltfModel { + std::vector vertices; + std::vector indices; + std::vector prims; + std::vector materials; + std::vector textures; + std::vector wheels; + float bmin[3] = {1e30f, 1e30f, 1e30f}, bmax[3] = {-1e30f, -1e30f, -1e30f}; +}; + +// 실패하면 std::runtime_error. +GltfModel load_gltf(const std::string& path); + +} // namespace vkgs diff --git a/src/vkgs/src/ply.cpp b/src/vkgs/src/ply.cpp new file mode 100644 index 0000000000000000000000000000000000000000..b784549726fc149742c7eb880a12a29626fa521e --- /dev/null +++ b/src/vkgs/src/ply.cpp @@ -0,0 +1,130 @@ +#include "ply.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace vkgs { + +Scene load_ply(const std::string& path) { + std::ifstream f(path, std::ios::binary); + if (!f) throw std::runtime_error("열 수 없음: " + path); + + std::string line; + std::getline(f, line); + if (line != "ply") throw std::runtime_error(path + ": ply 파일이 아닙니다"); + + Scene scene; + std::vector props; + size_t n = 0; + while (std::getline(f, line)) { + std::istringstream ss(line); + std::string tok; + ss >> tok; + if (tok == "format") { + std::string fmt; + ss >> fmt; + if (fmt != "binary_little_endian") throw std::runtime_error(path + ": " + fmt + " 미지원"); + } else if (tok == "comment") { + std::string tag; + ss >> tag; + if (tag == "nusim_origin") ss >> scene.origin[0] >> scene.origin[1] >> scene.origin[2]; + } else if (tok == "element") { + std::string name; + ss >> name >> n; + if (name != "vertex") throw std::runtime_error(path + ": vertex 외 element 미지원"); + } else if (tok == "property") { + std::string type, name; + ss >> type >> name; + if (type != "float") throw std::runtime_error(path + ": float 가 아닌 속성 " + name); + props.push_back(name); + } else if (tok == "end_header") { + break; + } + } + + std::unordered_map col; + for (size_t i = 0; i < props.size(); ++i) col[props[i]] = i; + auto need = [&](const std::string& k) { + auto it = col.find(k); + if (it == col.end()) throw std::runtime_error(path + ": 속성 " + k + " 없음"); + return it->second; + }; + size_t ix = need("x"), iy = need("y"), iz = need("z"), iop = need("opacity"); + size_t is[3] = {need("scale_0"), need("scale_1"), need("scale_2")}; + size_t ir[4] = {need("rot_0"), need("rot_1"), need("rot_2"), need("rot_3")}; + size_t idc[3] = {need("f_dc_0"), need("f_dc_1"), need("f_dc_2")}; + size_t n_rest = 0; + while (col.count("f_rest_" + std::to_string(n_rest))) ++n_rest; + if (n_rest % 3) throw std::runtime_error(path + ": f_rest 개수가 3의 배수가 아님"); + size_t k_rest = n_rest / 3; // 채널당 고차 계수 개수 + scene.sh_degree = static_cast(std::lround(std::sqrt(double(k_rest + 1)))) - 1; + if ((scene.sh_degree + 1) * (scene.sh_degree + 1) != int(k_rest + 1) || scene.sh_degree > 3) + throw std::runtime_error(path + ": SH 계수 개수가 이상함"); + std::vector irest(n_rest); + for (size_t i = 0; i < n_rest; ++i) irest[i] = col["f_rest_" + std::to_string(i)]; + + std::vector row(props.size()); + scene.gaussians.resize(n); + scene.bbox_min = {1e30f, 1e30f, 1e30f}; + scene.bbox_max = {-1e30f, -1e30f, -1e30f}; + for (size_t v = 0; v < n; ++v) { + if (!f.read(reinterpret_cast(row.data()), row.size() * sizeof(float))) + throw std::runtime_error(path + ": 데이터가 잘림"); + GpuGaussian& g = scene.gaussians[v]; + std::memset(&g, 0, sizeof(g)); + float p[3] = {row[ix], row[iy], row[iz]}; + for (int a = 0; a < 3; ++a) { + g.pos_opacity[a] = p[a]; + scene.bbox_min[a] = std::min(scene.bbox_min[a], p[a]); + scene.bbox_max[a] = std::max(scene.bbox_max[a], p[a]); + } + g.pos_opacity[3] = 1.0f / (1.0f + std::exp(-row[iop])); + + // Σ = R S Sᵀ Rᵀ. 쿼터니언은 (w,x,y,z), 정규화 전 값이 저장돼 있다. + float qw = row[ir[0]], qx = row[ir[1]], qy = row[ir[2]], qz = row[ir[3]]; + float qn = std::sqrt(qw * qw + qx * qx + qy * qy + qz * qz); + if (qn < 1e-12f) { qw = 1; qx = qy = qz = 0; qn = 1; } + qw /= qn; qx /= qn; qy /= qn; qz /= qn; + float R[3][3] = { + {1 - 2 * (qy * qy + qz * qz), 2 * (qx * qy - qw * qz), 2 * (qx * qz + qw * qy)}, + {2 * (qx * qy + qw * qz), 1 - 2 * (qx * qx + qz * qz), 2 * (qy * qz - qw * qx)}, + {2 * (qx * qz - qw * qy), 2 * (qy * qz + qw * qx), 1 - 2 * (qx * qx + qy * qy)}, + }; + float s[3] = {std::exp(row[is[0]]), std::exp(row[is[1]]), std::exp(row[is[2]])}; + float M[3][3]; + for (int r = 0; r < 3; ++r) + for (int c = 0; c < 3; ++c) M[r][c] = R[r][c] * s[c]; + float S[3][3]; + for (int r = 0; r < 3; ++r) + for (int c = 0; c < 3; ++c) S[r][c] = M[r][0] * M[c][0] + M[r][1] * M[c][1] + M[r][2] * M[c][2]; + g.cov_a[0] = S[0][0]; g.cov_a[1] = S[0][1]; g.cov_a[2] = S[0][2]; g.cov_a[3] = S[1][1]; + g.cov_b[0] = S[1][2]; g.cov_b[1] = S[2][2]; + + // SH: f_dc → 계수 0, f_rest 는 채널 우선 (R 의 k_rest 개, G, B) + for (int c = 0; c < 3; ++c) g.sh[c] = row[idc[c]]; + for (size_t k = 1; k <= k_rest; ++k) + for (int c = 0; c < 3; ++c) g.sh[k * 3 + c] = row[irest[c * k_rest + (k - 1)]]; + } + // 시맨틱 라벨 (nusim recon semantic, ply 옆 semantic.rgba = 가우시안마다 RGBA8): 빈 칸 cov_b[3] 에 비트 그대로 넣는다 + // — 시맨틱 카메라 모드에서 gs_preprocess.comp 가 SH 색 대신 쓴다. 없으면 0 (= 라벨 없음, A = 0) + std::string sem = path.substr(0, path.find_last_of('/') + 1) + "semantic.rgba"; + if (FILE* f = std::fopen(sem.c_str(), "rb")) { + std::vector lab(scene.gaussians.size()); + size_t got = std::fread(lab.data(), 4, lab.size(), f); + bool exact = got == lab.size() && std::fgetc(f) == EOF; + std::fclose(f); + if (exact) + for (size_t i = 0; i < lab.size(); ++i) std::memcpy(&scene.gaussians[i].cov_b[3], &lab[i], 4); + else + std::fprintf(stderr, " %s 크기가 ply 와 다름 — 무시 (nusim recon semantic 로 다시)\n", sem.c_str()); + } + return scene; +} + +} // namespace vkgs diff --git a/src/vkgs/src/ply.hpp b/src/vkgs/src/ply.hpp new file mode 100644 index 0000000000000000000000000000000000000000..9b1b81bb7830e4c019e75b4669fee671e00eecb8 --- /dev/null +++ b/src/vkgs/src/ply.hpp @@ -0,0 +1,29 @@ +// 표준 3DGS .ply 로더. 포맷 정의는 nusim/io/ply.py 가 유일한 출처다. +#pragma once + +#include +#include +#include + +namespace vkgs { + +// GPU 레이아웃과 같다 (shaders/gs_common.glsl 의 Gaussian, vec4 15개 = 240 B). +struct GpuGaussian { + float pos_opacity[4]; // 위치(씬 원점 기준), sigmoid(opacity) + float cov_a[4]; // Σ xx xy xz yy + float cov_b[4]; // Σ yz zz - - + float sh[48]; // 계수 k 의 채널 c = sh[k*3+c] +}; +static_assert(sizeof(GpuGaussian) == 240, "GPU 레이아웃과 어긋남"); + +struct Scene { + std::vector gaussians; + int sh_degree = 0; + std::array origin{0, 0, 0}; // 'comment nusim_origin' — 전역 = 저장값 + origin + std::array bbox_min{}, bbox_max{}; +}; + +// 실패하면 std::runtime_error. +Scene load_ply(const std::string& path); + +} // namespace vkgs diff --git a/src/vkgs/src/probe.cpp b/src/vkgs/src/probe.cpp new file mode 100644 index 0000000000000000000000000000000000000000..af4ecd5198be99f9309c5d08abd58a6eed658ce1 --- /dev/null +++ b/src/vkgs/src/probe.cpp @@ -0,0 +1,380 @@ +// vkgs_probe — 컨테이너의 Vulkan 경로를 끝까지 확인한다. +// +// 왜 필요한가: Docker + NVIDIA 에서 Vulkan 은 조용히 반쯤 망가지는 방식이 많다. +// * NVIDIA_DRIVER_CAPABILITIES 에 graphics 가 빠지면 ICD 는 주입되지만 +// vkEnumeratePhysicalDevices 가 0을 돌려준다 — 에러는 없다. +// * ICD json 은 있는데 libGLX_nvidia 버전이 어긋나면 인스턴스 생성에서 죽는다. +// * 컴퓨트 큐는 잡히는데 SPIR-V 버전이 안 맞아 파이프라인 생성만 실패한다. +// 그래서 열거만 보지 않고 실제로 디스패치해서 결과 숫자까지 맞춰 본다. +// +// 또한 3DGS 컴퓨트 래스터라이저가 요구하는 기능(subgroup, buffer device address, +// 워크그룹 한계, 메모리 힙)을 보고해 이 GPU 에서 무엇이 가능한지 미리 알려 준다. + +#include + +#include +#include +#include +#include +#include +#include + +#define VK_CHECK(expr) \ + do { \ + VkResult _r = (expr); \ + if (_r != VK_SUCCESS) { \ + std::cerr << "\033[1;31m ✘ " << #expr << " -> " << _r \ + << " (" << __FILE__ << ":" << __LINE__ << ")\033[0m\n"; \ + return 1; \ + } \ + } while (0) + +namespace { + +void ok(const std::string& label, const std::string& detail = "") { + std::cout << " \033[1;32m✔\033[0m " << label; + if (!detail.empty()) std::cout << " " << detail; + std::cout << "\n"; +} +void bad(const std::string& label, const std::string& detail = "") { + std::cout << " \033[1;31m✘\033[0m " << label; + if (!detail.empty()) std::cout << " " << detail; + std::cout << "\n"; +} +void head(const std::string& t) { std::cout << "\n\033[1;36m" << t << "\033[0m\n"; } + +std::string apiVersionString(uint32_t v) { + return std::to_string(VK_API_VERSION_MAJOR(v)) + "." + + std::to_string(VK_API_VERSION_MINOR(v)) + "." + + std::to_string(VK_API_VERSION_PATCH(v)); +} + +std::vector readFile(const std::string& path) { + std::ifstream f(path, std::ios::ate | std::ios::binary); + if (!f) return {}; + size_t n = static_cast(f.tellg()); + std::vector buf(n); + f.seekg(0); + f.read(buf.data(), static_cast(n)); + return buf; +} + +// 요청한 속성을 만족하는 메모리 타입을 찾는다. +int findMemoryType(const VkPhysicalDeviceMemoryProperties& props, uint32_t typeBits, + VkMemoryPropertyFlags want) { + for (uint32_t i = 0; i < props.memoryTypeCount; ++i) { + if ((typeBits & (1u << i)) && (props.memoryTypes[i].propertyFlags & want) == want) + return static_cast(i); + } + return -1; +} + +} // namespace + +int main() { + std::cout << "\033[1mvkgs_probe — Vulkan 환경 검증\033[0m\n"; + + // ---------------------------------------------------------------- 인스턴스 + head("인스턴스"); + uint32_t loaderVersion = VK_API_VERSION_1_0; + if (auto fn = reinterpret_cast( + vkGetInstanceProcAddr(nullptr, "vkEnumerateInstanceVersion"))) { + fn(&loaderVersion); + } + ok("로더 API", apiVersionString(loaderVersion)); + + VkApplicationInfo app{}; + app.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; + app.pApplicationName = "vkgs_probe"; + app.apiVersion = VK_API_VERSION_1_2; // 3DGS 래스터라이저가 요구하는 하한 + + VkInstanceCreateInfo ici{}; + ici.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; + ici.pApplicationInfo = &app; + + VkInstance instance = VK_NULL_HANDLE; + VK_CHECK(vkCreateInstance(&ici, nullptr, &instance)); + ok("vkCreateInstance", "Vulkan 1.2"); + + // ---------------------------------------------------------------- 물리장치 + head("물리 장치"); + uint32_t nDev = 0; + VK_CHECK(vkEnumeratePhysicalDevices(instance, &nDev, nullptr)); + if (nDev == 0) { + bad("장치 0개", + "ICD 는 로드됐지만 GPU 가 안 보입니다.\n" + " 컨테이너라면 NVIDIA_DRIVER_CAPABILITIES 에 'graphics' 가 있는지 확인하세요\n" + " (compute,utility 만으로는 CUDA 는 되고 Vulkan 은 안 됩니다)."); + vkDestroyInstance(instance, nullptr); + return 1; + } + std::vector devs(nDev); + VK_CHECK(vkEnumeratePhysicalDevices(instance, &nDev, devs.data())); + + // 이산 GPU 를 우선 고른다. 컨테이너에 llvmpipe(소프트웨어)가 같이 잡히는 일이 흔하다. + VkPhysicalDevice phys = devs[0]; + for (auto d : devs) { + VkPhysicalDeviceProperties p{}; + vkGetPhysicalDeviceProperties(d, &p); + std::cout << " · " << p.deviceName << " (API " << apiVersionString(p.apiVersion) + << ", type " << p.deviceType << ")\n"; + if (p.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) phys = d; + } + + VkPhysicalDeviceSubgroupProperties subgroup{}; + subgroup.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SUBGROUP_PROPERTIES; + VkPhysicalDeviceProperties2 props2{}; + props2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2; + props2.pNext = &subgroup; + vkGetPhysicalDeviceProperties2(phys, &props2); + const VkPhysicalDeviceProperties& p = props2.properties; + + ok("선택", std::string(p.deviceName) + " driver " + std::to_string(p.driverVersion)); + if (p.deviceType != VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) { + bad("장치 종류", "이산 GPU 가 아닙니다 — 소프트웨어 래스터라이저(llvmpipe)일 수 있습니다"); + } + + // ---------------------------------------------------- 3DGS 가 요구하는 기능 + head("3DGS 컴퓨트 래스터라이저 요구사항"); + ok("subgroup 크기", std::to_string(subgroup.subgroupSize) + + (subgroup.subgroupSize == 32 ? " (NVIDIA warp)" : "")); + const bool hasBallot = subgroup.supportedOperations & VK_SUBGROUP_FEATURE_BALLOT_BIT; + const bool hasArith = subgroup.supportedOperations & VK_SUBGROUP_FEATURE_ARITHMETIC_BIT; + // per-tile 알파 블렌딩에서 조기 종료 판정과 접두합에 쓴다. + (hasBallot ? ok : bad)("subgroup ballot", hasBallot ? "" : "타일 조기종료 최적화 불가"); + (hasArith ? ok : bad)("subgroup arithmetic", hasArith ? "" : "접두합을 별도 패스로 해야 함"); + + VkPhysicalDeviceVulkan12Features v12{}; + v12.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES; + VkPhysicalDeviceFeatures2 feats{}; + feats.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2; + feats.pNext = &v12; + vkGetPhysicalDeviceFeatures2(phys, &feats); + // 가우시안 버퍼를 디스크립터 없이 포인터로 넘길 때 쓴다 (바인딩 오버헤드 제거). + (v12.bufferDeviceAddress ? ok : bad)("bufferDeviceAddress", + v12.bufferDeviceAddress ? "" : "디스크립터 방식으로 우회 필요"); + (v12.timelineSemaphore ? ok : bad)("timelineSemaphore", + v12.timelineSemaphore ? "" : "프레임 동기화를 fence 로 우회"); + (v12.shaderFloat16 ? ok : bad)("shaderFloat16", + v12.shaderFloat16 ? " SH 계수를 fp16 으로 저장 가능 (VRAM 절반)" + : " fp32 로만 저장 — 8 GB 에서는 부담"); + + ok("maxComputeWorkGroupInvocations", std::to_string(p.limits.maxComputeWorkGroupInvocations)); + ok("maxComputeSharedMemorySize", std::to_string(p.limits.maxComputeSharedMemorySize / 1024) + " KB"); + ok("maxStorageBufferRange", std::to_string(p.limits.maxStorageBufferRange / (1024ull * 1024)) + " MB"); + + // ---------------------------------------------------------------- 메모리 + head("메모리"); + VkPhysicalDeviceMemoryProperties mem{}; + vkGetPhysicalDeviceMemoryProperties(phys, &mem); + VkDeviceSize deviceLocal = 0; + for (uint32_t i = 0; i < mem.memoryHeapCount; ++i) { + if (mem.memoryHeaps[i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) + deviceLocal += mem.memoryHeaps[i].size; + } + const double vramGB = static_cast(deviceLocal) / (1024.0 * 1024.0 * 1024.0); + ok("device-local VRAM", std::to_string(static_cast(vramGB * 10) / 10.0) + " GB"); + if (vramGB < 10.0) { + std::cout << " \033[1;33m참고: 가우시안 1개가 fp32 기준 약 236 B (위치12+스케일12+회전16" + << "+불투명도4+SH 192). 8 GB 면 렌더링 전용으로 약 2,000만 개가 상한입니다.\033[0m\n"; + } + + // ---------------------------------------------------------------- 컴퓨트 큐 + head("컴퓨트 디스패치 실측"); + uint32_t nQ = 0; + vkGetPhysicalDeviceQueueFamilyProperties(phys, &nQ, nullptr); + std::vector qs(nQ); + vkGetPhysicalDeviceQueueFamilyProperties(phys, &nQ, qs.data()); + + uint32_t computeFamily = UINT32_MAX; + for (uint32_t i = 0; i < nQ; ++i) { + if (qs[i].queueFlags & VK_QUEUE_COMPUTE_BIT) { computeFamily = i; break; } + } + if (computeFamily == UINT32_MAX) { bad("컴퓨트 큐", "없음"); return 1; } + ok("컴퓨트 큐 패밀리", "#" + std::to_string(computeFamily) + + " (큐 " + std::to_string(qs[computeFamily].queueCount) + "개)"); + + float prio = 1.0f; + VkDeviceQueueCreateInfo qci{}; + qci.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO; + qci.queueFamilyIndex = computeFamily; + qci.queueCount = 1; + qci.pQueuePriorities = &prio; + + VkDeviceCreateInfo dci{}; + dci.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; + dci.queueCreateInfoCount = 1; + dci.pQueueCreateInfos = &qci; + + VkDevice device = VK_NULL_HANDLE; + VK_CHECK(vkCreateDevice(phys, &dci, nullptr, &device)); + VkQueue queue; + vkGetDeviceQueue(device, computeFamily, 0, &queue); + ok("vkCreateDevice", "논리 장치 생성"); + + // --- 버퍼 --- + constexpr uint32_t kCount = 1024; + const VkDeviceSize bytes = kCount * sizeof(uint32_t); + + VkBufferCreateInfo bci{}; + bci.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bci.size = bytes; + bci.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; + bci.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + VkBuffer buffer; + VK_CHECK(vkCreateBuffer(device, &bci, nullptr, &buffer)); + + VkMemoryRequirements req{}; + vkGetBufferMemoryRequirements(device, buffer, &req); + // HOST_VISIBLE|HOST_COHERENT 로 잡아 스테이징 없이 바로 쓰고 읽는다. 프로브이므로 성능은 무관. + int mt = findMemoryType(mem, req.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (mt < 0) { bad("메모리 타입", "host-visible 힙을 찾지 못했습니다"); return 1; } + + VkMemoryAllocateInfo mai{}; + mai.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + mai.allocationSize = req.size; + mai.memoryTypeIndex = static_cast(mt); + VkDeviceMemory memory; + VK_CHECK(vkAllocateMemory(device, &mai, nullptr, &memory)); + VK_CHECK(vkBindBufferMemory(device, buffer, memory, 0)); + + uint32_t* mapped = nullptr; + VK_CHECK(vkMapMemory(device, memory, 0, bytes, 0, reinterpret_cast(&mapped))); + for (uint32_t i = 0; i < kCount; ++i) mapped[i] = i; + + // --- 셰이더 --- + const std::string spv = std::string(VKGS_SPIRV_DIR) + "/smoke.comp.spv"; + auto code = readFile(spv); + if (code.empty()) { bad("SPIR-V", spv + " 를 읽지 못했습니다 — 빌드가 덜 됐습니다"); return 1; } + ok("SPIR-V 로드", spv + " (" + std::to_string(code.size()) + " B)"); + + VkShaderModuleCreateInfo smci{}; + smci.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; + smci.codeSize = code.size(); + smci.pCode = reinterpret_cast(code.data()); + VkShaderModule shader; + VK_CHECK(vkCreateShaderModule(device, &smci, nullptr, &shader)); + + VkDescriptorSetLayoutBinding bind{}; + bind.binding = 0; + bind.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + bind.descriptorCount = 1; + bind.stageFlags = VK_SHADER_STAGE_COMPUTE_BIT; + VkDescriptorSetLayoutCreateInfo dslci{}; + dslci.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + dslci.bindingCount = 1; + dslci.pBindings = &bind; + VkDescriptorSetLayout dsl; + VK_CHECK(vkCreateDescriptorSetLayout(device, &dslci, nullptr, &dsl)); + + VkPushConstantRange pcr{VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(uint32_t)}; + VkPipelineLayoutCreateInfo plci{}; + plci.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + plci.setLayoutCount = 1; + plci.pSetLayouts = &dsl; + plci.pushConstantRangeCount = 1; + plci.pPushConstantRanges = &pcr; + VkPipelineLayout layout; + VK_CHECK(vkCreatePipelineLayout(device, &plci, nullptr, &layout)); + + VkComputePipelineCreateInfo cpci{}; + cpci.sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO; + cpci.stage.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + cpci.stage.stage = VK_SHADER_STAGE_COMPUTE_BIT; + cpci.stage.module = shader; + cpci.stage.pName = "main"; + cpci.layout = layout; + VkPipeline pipeline; + VK_CHECK(vkCreateComputePipelines(device, VK_NULL_HANDLE, 1, &cpci, nullptr, &pipeline)); + ok("컴퓨트 파이프라인", "생성 완료"); + + // --- 디스크립터 --- + VkDescriptorPoolSize psz{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1}; + VkDescriptorPoolCreateInfo dpci{}; + dpci.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + dpci.maxSets = 1; + dpci.poolSizeCount = 1; + dpci.pPoolSizes = &psz; + VkDescriptorPool pool; + VK_CHECK(vkCreateDescriptorPool(device, &dpci, nullptr, &pool)); + + VkDescriptorSetAllocateInfo dsai{}; + dsai.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + dsai.descriptorPool = pool; + dsai.descriptorSetCount = 1; + dsai.pSetLayouts = &dsl; + VkDescriptorSet set; + VK_CHECK(vkAllocateDescriptorSets(device, &dsai, &set)); + + VkDescriptorBufferInfo dbi{buffer, 0, bytes}; + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = set; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + write.pBufferInfo = &dbi; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + + // --- 기록 & 제출 --- + VkCommandPoolCreateInfo cpi{}; + cpi.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + cpi.queueFamilyIndex = computeFamily; + VkCommandPool cmdPool; + VK_CHECK(vkCreateCommandPool(device, &cpi, nullptr, &cmdPool)); + + VkCommandBufferAllocateInfo cbai{}; + cbai.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + cbai.commandPool = cmdPool; + cbai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + cbai.commandBufferCount = 1; + VkCommandBuffer cmd; + VK_CHECK(vkAllocateCommandBuffers(device, &cbai, &cmd)); + + VkCommandBufferBeginInfo cbbi{}; + cbbi.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + cbbi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + VK_CHECK(vkBeginCommandBuffer(cmd, &cbbi)); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipeline); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, layout, 0, 1, &set, 0, nullptr); + uint32_t count = kCount; + vkCmdPushConstants(cmd, layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(count), &count); + vkCmdDispatch(cmd, (kCount + 63) / 64, 1, 1); + VK_CHECK(vkEndCommandBuffer(cmd)); + + VkSubmitInfo si{}; + si.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + si.commandBufferCount = 1; + si.pCommandBuffers = &cmd; + VK_CHECK(vkQueueSubmit(queue, 1, &si, VK_NULL_HANDLE)); + VK_CHECK(vkQueueWaitIdle(queue)); + + // --- 검증: values[i] = i*2 + i = i*3 --- + uint32_t wrong = 0; + for (uint32_t i = 0; i < kCount; ++i) { + if (mapped[i] != i * 3u) ++wrong; + } + if (wrong == 0) { + ok("디스패치 결과", std::to_string(kCount) + "개 원소 전부 일치 (values[i] == i*3)"); + } else { + bad("디스패치 결과", std::to_string(wrong) + "개 불일치 — GPU 계산이 틀렸습니다"); + } + + // --- 정리 --- + vkDestroyCommandPool(device, cmdPool, nullptr); + vkDestroyDescriptorPool(device, pool, nullptr); + vkDestroyPipeline(device, pipeline, nullptr); + vkDestroyPipelineLayout(device, layout, nullptr); + vkDestroyDescriptorSetLayout(device, dsl, nullptr); + vkDestroyShaderModule(device, shader, nullptr); + vkUnmapMemory(device, memory); + vkFreeMemory(device, memory, nullptr); + vkDestroyBuffer(device, buffer, nullptr); + vkDestroyDevice(device, nullptr); + vkDestroyInstance(instance, nullptr); + + std::cout << (wrong == 0 ? "\n\033[1;32mVulkan 경로 정상 — 렌더러 개발을 시작할 수 있습니다\033[0m\n" + : "\n\033[1;31m검증 실패\033[0m\n"); + return wrong == 0 ? 0 : 1; +} diff --git a/src/vkgs/src/render_graph.cpp b/src/vkgs/src/render_graph.cpp new file mode 100644 index 0000000000000000000000000000000000000000..2e1989799146fa6bdcb16dfb6c4f2f60d2fc5a12 --- /dev/null +++ b/src/vkgs/src/render_graph.cpp @@ -0,0 +1,285 @@ +#include "render_graph.hpp" + +#include +#include +#include +#include +#include + +namespace vkgs { + +namespace { +struct UsageInfo { + const char* name; + VkPipelineStageFlags stage; + VkAccessFlags access; + VkImageLayout layout; // 이미지일 때 + bool write; +}; +const UsageInfo kUsage[] = { + {"storage_read", VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + VK_ACCESS_SHADER_READ_BIT, VK_IMAGE_LAYOUT_GENERAL, false}, + {"storage_write", VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_ACCESS_SHADER_WRITE_BIT, VK_IMAGE_LAYOUT_GENERAL, true}, + {"storage_read_write", VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT, + VK_IMAGE_LAYOUT_GENERAL, true}, + {"uniform", VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + VK_ACCESS_UNIFORM_READ_BIT, VK_IMAGE_LAYOUT_UNDEFINED, false}, + {"vertex", VK_PIPELINE_STAGE_VERTEX_SHADER_BIT, VK_ACCESS_SHADER_READ_BIT, VK_IMAGE_LAYOUT_UNDEFINED, false}, + {"indirect", VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT, VK_ACCESS_INDIRECT_COMMAND_READ_BIT, VK_IMAGE_LAYOUT_UNDEFINED, + false}, + {"color_attachment", VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + true}, + {"depth_attachment", VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT, + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT, + VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, true}, + {"transfer_src", VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_READ_BIT, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + false}, + {"transfer_dst", VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + true}, +}; +const UsageInfo& info(Usage u) { return kUsage[static_cast(u)]; } +} // namespace + +Usage parse_usage(const std::string& s) { + for (int k = 0; k < int(std::size(kUsage)); ++k) + if (s == kUsage[k].name) return static_cast(k); + throw std::runtime_error("렌더 그래프: 모르는 사용 방식 '" + s + "'"); +} +const char* usage_name(Usage u) { return info(u).name; } +bool usage_writes(Usage u) { return info(u).write; } + +void RenderGraph::load(const nlohmann::json& j) { + passes_.clear(); + res_.clear(); + for (auto& [name, d] : j.at("resources").items()) { + if (name.rfind("_doc", 0) == 0) continue; + RGResource r; + r.name = name; + r.type = d.value("type", "buffer"); + r.desc = d; + res_[name] = r; + } + for (const auto& p : j.at("passes")) { + RGPass pass; + pass.name = p.at("name").get(); + pass.kind = p.at("kind").get(); + pass.params = p.value("params", nlohmann::json::object()); + pass.enabled = p.value("enabled", true); + pass.optional = p.value("optional", false); + pass.follows = p.value("follows", ""); + for (const auto& u : p.value("uses", nlohmann::json::array())) { + RGUse use{u.at(0).get(), parse_usage(u.at(1).get())}; + if (!res_.count(use.resource)) + throw std::runtime_error("렌더 그래프: 패스 " + pass.name + " 가 정의되지 않은 리소스 " + use.resource + " 를 씀"); + pass.uses.push_back(use); + } + passes_.push_back(std::move(pass)); + } + for (const auto& p : passes_) + if (!p.follows.empty() && !find(p.follows)) + throw std::runtime_error("렌더 그래프: 패스 " + p.name + " 의 follows '" + p.follows + "' 가 없음"); + compile(); +} + +void RenderGraph::compile() { + // 리소스마다 선언 순서로 보아, 쓰는 패스 → 그 뒤 읽는/쓰는 패스 간선 (그리고 읽기 → 다음 쓰기: WAR) + int n = int(passes_.size()); + std::vector> out(n); + std::vector indeg(n, 0); + std::map last_writer; + std::map> readers_since; + for (int i = 0; i < n; ++i) { + for (const auto& u : passes_[i].uses) { + auto w = last_writer.find(u.resource); + if (w != last_writer.end() && w->second != i) out[w->second].insert(i); + if (usage_writes(u.usage)) { + for (int r : readers_since[u.resource]) + if (r != i) out[r].insert(i); + readers_since[u.resource].clear(); + } else { + readers_since[u.resource].push_back(i); + } + } + for (const auto& u : passes_[i].uses) + if (usage_writes(u.usage)) last_writer[u.resource] = i; + } + for (int i = 0; i < n; ++i) + for (int j : out[i]) ++indeg[j]; + // Kahn — 같은 단계에서는 선언 순서 (작은 번호 먼저) + std::priority_queue, std::greater> q; + for (int i = 0; i < n; ++i) + if (!indeg[i]) q.push(i); + order_.clear(); + while (!q.empty()) { + int i = q.top(); + q.pop(); + order_.push_back(i); + for (int j : out[i]) + if (--indeg[j] == 0) q.push(j); + } + if (int(order_.size()) != n) throw std::runtime_error("렌더 그래프에 순환이 있음"); +} + +void RenderGraph::bind_buffer(const std::string& name, VkBuffer b) { + auto it = res_.find(name); + if (it == res_.end()) throw std::runtime_error("렌더 그래프: 모르는 리소스 " + name); + it->second.buffer = b; +} +VkBuffer RenderGraph::buffer(const std::string& name) const { + auto it = res_.find(name); + if (it == res_.end() || !it->second.buffer) throw std::runtime_error("렌더 그래프: 버퍼 " + name + " 가 연결되지 않음"); + return it->second.buffer; +} + +VkImage RenderGraph::image(const std::string& name) const { + auto it = res_.find(name); + if (it == res_.end() || !it->second.image) throw std::runtime_error("렌더 그래프: 이미지 " + name + " 가 연결되지 않음"); + return it->second.image; +} + +void RenderGraph::bind_image(const std::string& name, VkImage im, VkImageAspectFlags aspect) { + auto it = res_.find(name); + if (it == res_.end()) throw std::runtime_error("렌더 그래프: 모르는 리소스 " + name); + it->second.image = im; + it->second.aspect = aspect; +} + +void RenderGraph::begin_frame() { + for (auto& [_, r] : res_) { + r.wstage = r.rstage = r.visible = 0; + r.waccess = 0; + r.layout = VK_IMAGE_LAYOUT_UNDEFINED; + } +} + +void RenderGraph::set_state(const std::string& res, VkPipelineStageFlags stage, VkAccessFlags access, + VkImageLayout layout) { + auto& r = res_.at(res); + r.wstage = stage; + r.waccess = access; + r.rstage = r.visible = 0; + r.layout = layout; +} + +void RenderGraph::barrier_for(VkCommandBuffer c, const RGUse& u) { + RGResource& r = res_.at(u.resource); + const UsageInfo& ui = info(u.usage); + if (u.usage == Usage::ColorAttachment || u.usage == Usage::DepthAttachment) + return; // 첨부는 렌더 패스(initial/final 레이아웃, 서브패스 의존성)가 맡는다 + bool is_image = r.type == "image"; + VkImageLayout want = is_image && ui.layout != VK_IMAGE_LAYOUT_UNDEFINED ? ui.layout : r.layout; + bool layout_change = is_image && want != r.layout; + VkPipelineStageFlags src = 0; + VkAccessFlags src_access = 0; + if (ui.write || layout_change) { + // 쓰기 — 레이아웃 전환도 쓰기다: 앞선 쓰기(WAW)와 그 뒤 읽기들(WAR)이 끝난 뒤 + src = r.wstage | r.rstage; + src_access = r.waccess; + } else if (r.wstage && (r.visible & ui.stage) != ui.stage) { + // 읽기: 마지막 쓰기가 아직 이 단계에 보이지 않으면 (RAW) + src = r.wstage; + src_access = r.waccess; + } + if (src || layout_change) { + if (!src) src = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; + if (is_image && r.image != VK_NULL_HANDLE) { + VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER}; + b.srcAccessMask = src_access; + b.dstAccessMask = ui.access; + b.oldLayout = r.layout; + b.newLayout = want; + b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + b.image = r.image; + b.subresourceRange = {r.aspect, 0, 1, 0, 1}; + vkCmdPipelineBarrier(c, src, ui.stage, 0, 0, nullptr, 0, nullptr, 1, &b); + } else { + VkMemoryBarrier b{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + b.srcAccessMask = src_access; + b.dstAccessMask = ui.access; + vkCmdPipelineBarrier(c, src, ui.stage, 0, 1, &b, 0, nullptr, 0, nullptr); + } + r.layout = want; + } + if (ui.write) { + r.wstage = ui.stage; + r.waccess = ui.access; + r.rstage = 0; + r.visible = ui.stage; // 같은 패스 안에서 이어 읽는 것은 실행기가 맡는다 + } else { + if (layout_change) { // 전환 뒤에는 이 단계만 본 상태 — 앞선 읽기들은 전환 전에 끝났다 + r.rstage = ui.stage; + r.visible = ui.stage; + } else { + r.rstage |= ui.stage; + r.visible |= ui.stage; + } + } +} + +void RenderGraph::traverse(VkCommandBuffer c, const std::function& fn) { + for (int i : order_) { + RGPass& p = passes_[i]; + if (!p.enabled) continue; + if (!p.follows.empty()) { + const RGPass* lead = find(p.follows); + if (!lead || !lead->enabled) continue; + } + for (const auto& u : p.uses) barrier_for(c, u); + fn(p); + // 첨부: 렌더 패스가 끝난 뒤의 상태 (finalLayout — 색 GENERAL, 깊이 DEPTH_STENCIL_ATTACHMENT_OPTIMAL) + for (const auto& u : p.uses) { + if (u.usage == Usage::ColorAttachment) + set_state(u.resource, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, + VK_IMAGE_LAYOUT_GENERAL); + else if (u.usage == Usage::DepthAttachment) + set_state(u.resource, VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT, + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL); + } + } +} + +RGPass* RenderGraph::find(const std::string& name) { + for (auto& p : passes_) + if (p.name == name) return &p; + return nullptr; +} + +std::string RenderGraph::to_dot() const { + std::ostringstream o; + o << "digraph render_graph {\n rankdir=LR;\n node [fontname=\"NanumGothic\"];\n"; + for (const auto& [name, r] : res_) + o << " \"r_" << name << "\" [label=\"" << name << "\\n(" << r.type << ")\", shape=" + << (r.type == "image" ? "note" : "cylinder") << ", style=filled, fillcolor=\"#e8f0fe\"];\n"; + int k = 0; + for (int i : order_) { + const auto& p = passes_[i]; + o << " \"p_" << p.name << "\" [label=\"" << ++k << ". " << p.name << "\\n[" << p.kind << "]\", shape=box, style=\"filled" + << (p.enabled ? "" : ",dashed") << "\", fillcolor=\"" << (p.enabled ? "#fde8c8" : "#eeeeee") << "\"];\n"; + for (const auto& u : p.uses) { + if (u.usage == Usage::StorageReadWrite) // 읽고 쓰기 — 양방향 (읽기 의존성이 그림에서 사라지지 않게) + o << " \"p_" << p.name << "\" -> \"r_" << u.resource << "\" [label=\"" << usage_name(u.usage) << "\", dir=both, color=\"#8e44ad\"];\n"; + else if (usage_writes(u.usage)) + o << " \"p_" << p.name << "\" -> \"r_" << u.resource << "\" [label=\"" << usage_name(u.usage) << "\", color=\"#c0392b\"];\n"; + else + o << " \"r_" << u.resource << "\" -> \"p_" << p.name << "\" [label=\"" << usage_name(u.usage) << "\", color=\"#2c6fbb\"];\n"; + } + } + o << "}\n"; + return o.str(); +} + +nlohmann::json RenderGraph::to_json() const { + nlohmann::json j; + for (int i : order_) { + const auto& p = passes_[i]; + nlohmann::json pj{{"name", p.name}, {"kind", p.kind}, {"enabled", p.enabled}, {"gpu_ms", p.gpu_ms}}; + for (const auto& u : p.uses) pj["uses"].push_back({u.resource, usage_name(u.usage)}); + j["order"].push_back(pj); + } + return j; +} + +} // namespace vkgs diff --git a/src/vkgs/src/render_graph.hpp b/src/vkgs/src/render_graph.hpp new file mode 100644 index 0000000000000000000000000000000000000000..15370679c382354917e39091224dca283bcbf52a --- /dev/null +++ b/src/vkgs/src/render_graph.hpp @@ -0,0 +1,97 @@ +// 렌더 그래프 — 패스(노드)·리소스·읽기/쓰기 관계를 데이터(configs/render_graph.json)로 정의하고, +// for 문 하나로 순회하며 실행한다. 배리어·이미지 레이아웃 전환은 패스가 선언한 리소스 사용(어떤 단계에서 +// 어떻게 읽고 쓰는지)과 리소스별 마지막 접근 상태로 자동으로 만든다. +// +// 실행 순서 = JSON 선언 순서다. 선언 순서가 곧 접근 순서를 정의하므로 의존 간선(RAW·WAW·WAR)은 항상 앞 패스 → +// 뒤 패스이고, 순환은 생길 수 없다. 간선은 그림(--graph)·문서와, 앞으로 큐 병렬화(독립 패스 찾기)에 쓴다. +// (2026-10-05 검토: 예전 문서는 '위상 정렬로 재배치' 라고 했지만 결과는 늘 선언 순서였다.) +// +// 엔진 코드는 '패스 종류'별 실행기만 갖는다 (compute 디스패치, bitonic 정렬, 버퍼 복사, 래스터 …). +// 어떤 패스를 어떤 순서·파이프라인·리소스로 돌릴지는 JSON 이 정한다 — 패스·그리기 항목을 늘려도 순회 코드는 그대로다. +#pragma once + +#include + +#include +#include +#include +#include + +#include "nlohmann/json.hpp" + +namespace vkgs { + +// 패스가 리소스를 쓰는 방식. 배리어의 단계·접근·레이아웃은 이것으로 정해진다. +enum class Usage { + StorageRead, StorageWrite, StorageReadWrite, // 컴퓨트·셰이더 SSBO/스토리지 이미지 + Uniform, // UBO + VertexRead, // 정점 셰이더가 SSBO 로 읽음 (vertex pulling) + Indirect, // 인다이렉트 인자 + ColorAttachment, DepthAttachment, // 래스터 패스 첨부 (렌더 패스가 전환한다) + TransferSrc, TransferDst, +}; +Usage parse_usage(const std::string& s); +const char* usage_name(Usage u); +bool usage_writes(Usage u); + +struct RGUse { + std::string resource; + Usage usage; +}; + +struct RGPass { + std::string name, kind; // kind = 실행기 이름 + nlohmann::json params; // 실행기별 인자 (파이프라인 이름, 디스패치 크기, 그리기 목록 …) + std::vector uses; + bool enabled = true; + bool optional = false; + std::string follows; // 이 패스를 따라 켜지고 꺼지는 짝 (예: fxaa_resolve → fxaa) // UI 에서 끌 수 있는 패스 (꺼도 뒤 패스가 읽을 리소스가 비지 않는 것만) + double gpu_ms = 0; // 마지막 프레임 GPU 시간 (타임스탬프) +}; + +struct RGResource { + std::string name, type; // type: buffer | image + nlohmann::json desc; // 형식·크기 등 (문서·시각화용; 실제 생성은 렌더러) + // 실행 중 상태 (자동 배리어용) + VkBuffer buffer = VK_NULL_HANDLE; + VkImage image = VK_NULL_HANDLE; + VkImageAspectFlags aspect = VK_IMAGE_ASPECT_COLOR_BIT; + VkPipelineStageFlags wstage = 0; // 마지막 쓰기의 단계 (0 = 이번 프레임에 쓴 적 없음) + VkAccessFlags waccess = 0; + VkPipelineStageFlags rstage = 0; // 마지막 쓰기 이후 읽은 단계들 (다음 쓰기의 WAR 의존성) + VkPipelineStageFlags visible = 0; // 마지막 쓰기가 이미 보이게 된 단계들 (같은 단계 읽기에 배리어를 또 넣지 않게) + VkImageLayout layout = VK_IMAGE_LAYOUT_UNDEFINED; +}; + +class RenderGraph { +public: + void load(const nlohmann::json& j); + // 의존 간선(생산자 → 소비자)을 만들고 실행 순서를 정한다 (= 선언 순서, 위 설명). + void compile(); + // 실행기가 실제 Vulkan 핸들을 알려 준다 (렌더러가 리소스를 만든 뒤) + void bind_buffer(const std::string& name, VkBuffer b); + void bind_image(const std::string& name, VkImage im, VkImageAspectFlags aspect = VK_IMAGE_ASPECT_COLOR_BIT); + VkBuffer buffer(const std::string& name) const; + VkImage image(const std::string& name) const; + // 프레임 시작: 리소스 상태를 '정의되지 않음' 으로 (프레임 사이는 펜스로 동기화된다) + void begin_frame(); + // 정렬된 순서로 켜진 패스마다 (배리어를 넣은 뒤) fn 을 부른다 + void traverse(VkCommandBuffer c, const std::function& fn); + // 패스가 끝난 뒤 리소스의 상태를 직접 알려 줄 때 (렌더 패스의 finalLayout 등) + void set_state(const std::string& res, VkPipelineStageFlags stage, VkAccessFlags access, VkImageLayout layout); + + std::string to_dot() const; + nlohmann::json to_json() const; + std::vector& passes() { return passes_; } + const std::map& resources() const { return res_; } + const std::vector& order() const { return order_; } + RGPass* find(const std::string& name); + +private: + void barrier_for(VkCommandBuffer c, const RGUse& u); + std::vector passes_; + std::map res_; + std::vector order_; +}; + +} // namespace vkgs diff --git a/src/vkgs/src/scene_objects.hpp b/src/vkgs/src/scene_objects.hpp new file mode 100644 index 0000000000000000000000000000000000000000..a01bbeda500e8cfec3597456416d120ba868a158 --- /dev/null +++ b/src/vkgs/src/scene_objects.hpp @@ -0,0 +1,250 @@ +// 장면 객체 — 학습이 차량마다 내보낸 3DGS 에셋(nusim.recon.objects: objects/objects.json + .ply)을 +// 시뮬레이터 안에서 각자의 자세로 그리고, 지우고, 더한다. +// +// 에셋 좌표: 박스 로컬, x 앞 · y 왼쪽 · z 위, 원점 = 박스 바닥 중앙 (recon/asset.py 규약). +// 자세: 주차 차량은 대표 자세 하나, 움직이는 차량은 nuScenes 키프레임(2 Hz) 박스를 시각으로 보간한다 — +// 위치는 선형, 회전은 nlerp (dynamic.box_at 과 같다). 궤적 범위 ±0.25 s 밖이면 숨긴다. +// 박스 자세 (t = 박스 중심, q) → 에셋 변환 = T(t − 씬 원점) · R(q) · T(0, 0, −h/2). +// +// GPU 쪽은 렌더러가 맡는다 (Renderer::set_scene_objects / set_object_table): 여기서는 '어떤 객체가 어디에'만 정한다. +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "nlohmann/json.hpp" +#include "ply.hpp" + +namespace vkgs { + +struct ObjKeyframe { + int64_t ts = 0; // µs + double t[3] = {0, 0, 0}; // 박스 중심 (전역) + double q[4] = {1, 0, 0, 0}; // wxyz +}; + +struct SceneObject { + int asset = -1; // ObjectWorld::assets 번호 + int instance = -1; // nuScenes instance (사용자가 더한 것은 -1) + std::string category; + std::array size{}; // w, l, h + bool is_static = true; + ObjKeyframe pose; // 정적 자세 (is_static) 또는 사용자가 옮긴 자세 + std::vector keys; // 움직이는 객체의 궤적 + bool deleted = false; + bool user_posed = false; // 사용자가 옮겼으면 궤적 대신 pose 를 쓴다 +}; + +// GPU 객체 표 한 칸 (gs_preprocess.comp 의 ObjInstance, std430) +struct ObjInstanceGpu { + float xform[16]; // 열 우선 + float flags[4]; // x = 보이기 +}; + +struct ObjAsset { + std::string name; + Scene scene; // 박스 로컬 가우시안 +}; + +class ObjectWorld { +public: + std::vector assets; + std::vector objects; + std::array origin{0, 0, 0}; // 씬 원점 (ply 의 nusim_origin) — 전역 − origin = 로컬 + + // objects/objects.json + .ply 를 읽는다 + void load(const std::filesystem::path& d, const std::array& scene_origin) { + dir = d; + origin = scene_origin; + std::ifstream f(d / "objects.json"); + if (!f) throw std::runtime_error("objects.json 이 없음: " + (d / "objects.json").string()); + nlohmann::json j = nlohmann::json::parse(f); + std::map by_name; + for (const auto& o : j.at("objects")) { + std::string an = o.at("asset"); + auto it = by_name.find(an); + int ai; + if (it == by_name.end()) { + ai = int(assets.size()); + assets.push_back({an, load_ply((d / an).string())}); + by_name[an] = ai; + } else { + ai = it->second; + } + SceneObject so; + so.asset = ai; + so.instance = o.value("instance", -1); + so.category = o.value("category", ""); + for (int k = 0; k < 3; ++k) so.size[k] = o.at("size_wlh")[k]; + so.is_static = o.value("static", true); + auto rd = [](const nlohmann::json& p, int64_t ts) { + ObjKeyframe kf; + kf.ts = ts; + for (int k = 0; k < 3; ++k) kf.t[k] = p.at("t")[k]; + for (int k = 0; k < 4; ++k) kf.q[k] = p.at("q")[k]; + return kf; + }; + if (so.is_static) so.pose = rd(o.at("pose"), 0); + else + for (const auto& kf : o.at("keyframes")) so.keys.push_back(rd(kf, kf.at("timestamp").get())); + objects.push_back(std::move(so)); + } + } + + // 사용자 추가: asset 을 (x, y, z 지면, yaw) 에 정적으로 둔다. 새 객체 번호 + int add(int asset, double x, double y, double z_ground, double yaw, const std::array& size, + const std::string& category) { + SceneObject so; + so.asset = asset; + so.category = category; + so.size = size; + so.is_static = true; + so.user_posed = true; + so.pose.t[0] = x; so.pose.t[1] = y; so.pose.t[2] = z_ground + size[2] * 0.5; + so.pose.q[0] = std::cos(yaw * 0.5); so.pose.q[3] = std::sin(yaw * 0.5); + objects.push_back(so); + return int(objects.size()) - 1; + } + + // 시각 ts(µs) 의 박스 자세. 없으면 false + bool pose_at(const SceneObject& o, int64_t ts, ObjKeyframe& out) const { + if (o.is_static || o.user_posed) { out = o.pose; return true; } + if (o.keys.empty()) return false; + const int64_t hold = 250000; + if (ts < o.keys.front().ts - hold || ts > o.keys.back().ts + hold) return false; + if (ts <= o.keys.front().ts) { out = o.keys.front(); return true; } + if (ts >= o.keys.back().ts) { out = o.keys.back(); return true; } + size_t j = 1; + while (o.keys[j].ts < ts) ++j; + const ObjKeyframe &a = o.keys[j - 1], &b = o.keys[j]; + double w = double(ts - a.ts) / double(b.ts - a.ts); + double dot = 0, n = 0; + for (int k = 0; k < 4; ++k) dot += a.q[k] * b.q[k]; + double sgn = dot < 0 ? -1.0 : 1.0; + for (int k = 0; k < 3; ++k) out.t[k] = (1 - w) * a.t[k] + w * b.t[k]; + for (int k = 0; k < 4; ++k) { out.q[k] = (1 - w) * a.q[k] + w * sgn * b.q[k]; n += out.q[k] * out.q[k]; } + n = std::sqrt(n); + for (int k = 0; k < 4; ++k) out.q[k] /= n; + out.ts = ts; + return true; + } + + // 박스 자세 → 에셋 변환 (열 우선 4x4, 씬 로컬 좌표) + void xform_of(const SceneObject& o, const ObjKeyframe& p, float m[16]) const { + double w = p.q[0], x = p.q[1], y = p.q[2], z = p.q[3]; + double R[3][3] = {{1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)}, + {2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)}, + {2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)}}; + double h2 = o.size[2] * 0.5; + for (int c = 0; c < 3; ++c) + for (int r = 0; r < 3; ++r) m[c * 4 + r] = float(R[r][c]); + for (int r = 0; r < 3; ++r) { + // T(t − origin) · R · T(0,0,−h/2): 평행이동 = (t − origin) − R·(0,0,h/2) + m[12 + r] = float(p.t[r] - origin[r] - R[r][2] * h2); + m[3 + 4 * r] = 0; + } + m[15] = 1; + } + + // GPU 배열: 지워지지 않은 객체의 에셋 가우시안을 이어 붙이고, 가우시안마다 객체 번호 + void build_gpu(std::vector& g, std::vector& ids) const { + g.clear(); + ids.clear(); + for (size_t k = 0; k < objects.size(); ++k) { + if (objects[k].deleted) continue; + const auto& src = assets[objects[k].asset].scene.gaussians; + g.insert(g.end(), src.begin(), src.end()); + ids.insert(ids.end(), src.size(), uint32_t(k)); + } + } + + // 객체 표 (시각 ts): 객체 번호 = objects 의 번호 (지운 것도 칸은 있다 — 보이기 0) + void table_at(int64_t ts, std::vector& t) const { + t.assign(objects.size(), ObjInstanceGpu{}); + for (size_t k = 0; k < objects.size(); ++k) { + ObjKeyframe p; + if (objects[k].deleted || !pose_at(objects[k], ts, p)) continue; + xform_of(objects[k], p, t[k].xform); + t[k].flags[0] = 1; + } + } + + std::filesystem::path dir; // 불러온 폴더 (저장할 곳) + + // 편집한 배치를 objects.json 으로 쓴다 (처음 저장할 때 원본을 objects.orig.json 으로 남긴다). + // 지운 객체는 빠지고, 사용자가 옮긴·더한 객체는 정적 자세로 저장된다. + void save() const { + namespace fs = std::filesystem; + fs::path f = dir / "objects.json", orig = dir / "objects.orig.json"; + if (fs::exists(f) && !fs::exists(orig)) fs::copy_file(f, orig); + nlohmann::json out; + out["origin"] = {origin[0], origin[1], origin[2]}; + out["objects"] = nlohmann::json::array(); + for (const auto& o : objects) { + if (o.deleted) continue; + nlohmann::json j; + j["asset"] = assets[o.asset].name; + j["instance"] = o.instance; + j["category"] = o.category; + j["size_wlh"] = {o.size[0], o.size[1], o.size[2]}; + j["n_gaussians"] = assets[o.asset].scene.gaussians.size(); + bool st = o.is_static || o.user_posed; + j["static"] = st; + if (st) { + j["pose"]["t"] = {o.pose.t[0], o.pose.t[1], o.pose.t[2]}; + j["pose"]["q"] = {o.pose.q[0], o.pose.q[1], o.pose.q[2], o.pose.q[3]}; + } else { + for (const auto& k : o.keys) + j["keyframes"].push_back({{"timestamp", k.ts}, {"t", {k.t[0], k.t[1], k.t[2]}}, + {"q", {k.q[0], k.q[1], k.q[2], k.q[3]}}}); + } + out["objects"].push_back(j); + } + std::ofstream(f) << out.dump(1) << "\n"; + } + + // 광선(씬 로컬)과 객체 k 의 박스 교차 거리 (없으면 −1). 에셋 좌표: x ∈ ±l/2, y ∈ ±w/2, z ∈ [0, h] + double ray_hit(size_t k, int64_t ts, const double o[3], const double d[3]) const { + ObjKeyframe p; + if (objects[k].deleted || !pose_at(objects[k], ts, p)) return -1; + float m[16]; + xform_of(objects[k], p, m); + double lo[3], ld[3]; // 광선을 에셋 좌표로: Rᵀ(o − t), Rᵀd + for (int c = 0; c < 3; ++c) { + lo[c] = ld[c] = 0; + for (int r = 0; r < 3; ++r) { + lo[c] += m[c * 4 + r] * (o[r] - m[12 + r]); + ld[c] += m[c * 4 + r] * d[r]; + } + } + const auto& s = objects[k].size; + double bmin[3] = {-s[1] * 0.5, -s[0] * 0.5, 0}, bmax[3] = {s[1] * 0.5, s[0] * 0.5, s[2]}; + double t0 = 0, t1 = 1e9; + for (int a = 0; a < 3; ++a) { + if (std::fabs(ld[a]) < 1e-12) { + if (lo[a] < bmin[a] || lo[a] > bmax[a]) return -1; + continue; + } + double ta = (bmin[a] - lo[a]) / ld[a], tb = (bmax[a] - lo[a]) / ld[a]; + if (ta > tb) std::swap(ta, tb); + t0 = std::max(t0, ta); + t1 = std::min(t1, tb); + if (t0 > t1) return -1; + } + return t0; + } + + // 박스 (중심, 회전, 크기) — 마우스 선택·충돌용. false = 이 시각에 없음 + bool box_at(size_t k, int64_t ts, ObjKeyframe& p) const { + return !objects[k].deleted && pose_at(objects[k], ts, p); + } +}; + +} // namespace vkgs diff --git a/src/vkgs/src/segment_map.hpp b/src/vkgs/src/segment_map.hpp new file mode 100644 index 0000000000000000000000000000000000000000..0f7f2cfeace8d2462ee457620fbf8e20499ff416 --- /dev/null +++ b/src/vkgs/src/segment_map.hpp @@ -0,0 +1,127 @@ +// 구간 모델 이어 달리기 — 맵 로더의 선택 로직 (헤더 전용, 창 없이 시험: tests/test_segment_map_cpp.py). +// +// 일괄 재구성은 긴 씬을 60 m 구간으로 나눠 구간마다 따로 학습한다 (nusim.recon.batch). 구간 모델은 각자 원점이 +// 다르고 자기 구간 밖은 비어 있다. 뷰어는 자차 위치에서 가장 가까운 구간 하나를 그리고, 다음 구간을 미리 읽어 두었다가 +// 경계를 넘으면 가우시안 버퍼를 바꾼다 (viewer.cpp SegmentStreamer). 이웃 구간은 10 m 겹치므로 경계에서 +// 한 모델만 그려도 빈 곳이 없다 — 두 모델을 같이 그리면 겹침에서 같은 물체가 두 번 그려진다. +// +// 파일 (nusim render view 가 만든다, segments.txt): +// S 구간 k: 그 ply 의 원점 − 공통 원점 (가우시안 위치에 더한다) +// P 구간 k 가 맡는 자차 궤적 점 (공통 원점 기준) +// 공통 원점 = 뷰어에 처음 넘긴 ply(구간 0)의 원점. 리그·맵 선·주행가능영역·장애물 지도도 이 원점 기준이다. +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace vkgs { + +struct MapSegment { + int k = 0; + std::string ply; + double offset[3] = {0, 0, 0}; + uint64_t n_gaussians = 0; + std::vector> own; // 맡는 궤적 점 + int world = -1; // 이 구간의 복셀 월드 (SegmentMap::worlds 번호, 없으면 -1) +}; + +// 씬 하나의 복셀 월드 (라이다·레이더 · nusim/sim/voxels.py) — 지역 맵에서는 씬마다 원점이 달라 offset 을 더한다 +struct MapWorld { + std::string path; + double offset[3] = {0, 0, 0}; +}; + +struct SegmentMap { + std::vector segs; + std::vector worlds; + float hysteresis = 3.0f; // 새 구간이 이만큼(m) 더 가까워야 바꾼다 — 경계에서 왔다 갔다 하지 않게 + + static SegmentMap load(const std::string& path) { + std::ifstream f(path); + if (!f) throw std::runtime_error("구간 목록을 못 엶: " + path); + SegmentMap m; + std::string line; + auto find = [&](int k) -> MapSegment& { + for (auto& s : m.segs) + if (s.k == k) return s; + throw std::runtime_error("segments: P 줄의 구간 " + std::to_string(k) + " 이 S 줄에 없음"); + }; + while (std::getline(f, line)) { + if (line.empty() || line[0] == '#') continue; + std::istringstream is(line); + char tag; + is >> tag; + if (tag == 'W') { // W ox oy oz path — 뒤따르는 S 구간들이 쓰는 복셀 월드 + MapWorld w; + is >> w.offset[0] >> w.offset[1] >> w.offset[2]; + std::getline(is >> std::ws, w.path); + m.worlds.push_back(w); + } else if (tag == 'S') { + MapSegment s; + is >> s.k >> s.n_gaussians >> s.offset[0] >> s.offset[1] >> s.offset[2]; + std::getline(is >> std::ws, s.ply); + s.world = int(m.worlds.size()) - 1; + m.segs.push_back(s); + } else if (tag == 'P') { + int k; + float x, y; + is >> k >> x >> y; + find(k).own.push_back({x, y}); + } + } + if (m.segs.empty()) throw std::runtime_error("구간 목록이 비었음: " + path); + return m; + } + + uint64_t max_gaussians() const { + uint64_t n = 0; + for (const auto& s : segs) n = std::max(n, s.n_gaussians); + return n; + } + + // 구간 i 가 맡은 궤적 점까지 가장 가까운 거리 + float distance(size_t i, float x, float y) const { + float best = std::numeric_limits::infinity(); + for (const auto& p : segs[i].own) best = std::min(best, std::hypot(p[0] - x, p[1] - y)); + return best; + } + + size_t nearest(float x, float y) const { + size_t bi = 0; + float bd = std::numeric_limits::infinity(); + for (size_t i = 0; i < segs.size(); ++i) { + float d = distance(i, x, y); + if (d < bd) bd = d, bi = i; + } + return bi; + } + + // 가까운 순으로 k 개 (미리 읽기용 — 여러 씬을 섞은 지역 맵에서는 목록 순서가 길 위의 이웃이 아니다) + std::vector nearest_k(float x, float y, size_t k) const { + std::vector> d; + d.reserve(segs.size()); + for (size_t i = 0; i < segs.size(); ++i) d.push_back({distance(i, x, y), i}); + k = std::min(k, d.size()); + std::partial_sort(d.begin(), d.begin() + k, d.end()); + std::vector out; + for (size_t i = 0; i < k; ++i) out.push_back(d[i].second); + return out; + } + + // 지금 그리는 구간(active)을 두고 (x, y) 에서 그릴 구간. 가장 가까운 구간이 hysteresis 이상 더 가까울 때만 바꾼다. + size_t pick(float x, float y, size_t active) const { + size_t n = nearest(x, y); + if (n == active || active >= segs.size()) return n; + return distance(n, x, y) + hysteresis < distance(active, x, y) ? n : active; + } +}; + +} // namespace vkgs diff --git a/src/vkgs/src/sensors.hpp b/src/vkgs/src/sensors.hpp new file mode 100644 index 0000000000000000000000000000000000000000..a423d0634ef79f41bdb672c160336d1883aaadd0 --- /dev/null +++ b/src/vkgs/src/sensors.hpp @@ -0,0 +1,408 @@ +// 레이캐스트 센서 — 라이다·레이더를 카메라처럼 자차에 붙인다 (헤더 전용, 창 없이 시험: tests/test_sensors_cpp.py). +// +// 카메라는 3DGS 를 그려 관측을 만들지만, 라이다·레이더는 광선을 쏴 부딪힌 곳을 돌려준다 (CARLA ray-cast 센서와 같은 방식). +// 부딪힐 기하 = 복셀 월드 (nusim/sim/voxels.py: 정적 라이다 누적 0.2 m 점유 격자 — 노면·연석·건물·나무) + 시뮬레이터가 +// 넣은 객체 상자 (다른 차량 등, 속도 포함). 3DGS 깊이를 쓰지 않는다 — floater·반투명 표면이 센서에 가짜 점을 만든다. +// +// 좌표: 씬 원점 기준 전역(맵) 좌표, z 위. 자차 원점 = 뒷바퀴 축 중앙 (nuScenes ego). 센서 장착 = 자차 기준 +// 위치 [x 앞, y 왼쪽, z 위] + 자세 [roll, pitch, yaw] (도). 센서 좌표 = x 앞, y 왼쪽, z 위 (nuScenes 라이다·레이더와 같다). +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace vkgs { + +struct VoxelWorld { + double lo[3] = {0, 0, 0}; + float res = 0.2f; + int n[3] = {0, 0, 0}; + std::vector bits; + double glo[2] = {0, 0}; // 지면 격자 (nusim/sim/voxels.py: 첫 빽빽한 층, 1 m) + float gres = 1.0f; + int gn[2] = {0, 0}; + std::vector ground; + + bool empty() const { return bits.empty(); } + // (x, y) 의 지면 높이 (이중 선형). 격자 밖이면 NaN + float ground_at(float x, float y) const { + if (ground.empty()) return std::nanf(""); + float fx = float((x - glo[0]) / gres) - 0.5f, fy = float((y - glo[1]) / gres) - 0.5f; + int ix = int(std::floor(fx)), iy = int(std::floor(fy)); + if (ix < 0 || iy < 0 || ix + 1 >= gn[0] || iy + 1 >= gn[1]) return std::nanf(""); + float ax = fx - ix, ay = fy - iy; + auto g = [&](int i, int j) { return ground[size_t(j) * gn[0] + i]; }; + return (1 - ay) * ((1 - ax) * g(ix, iy) + ax * g(ix + 1, iy)) + ay * ((1 - ax) * g(ix, iy + 1) + ax * g(ix + 1, iy + 1)); + } + void load(const std::string& path) { + std::ifstream f(path, std::ios::binary); + if (!f) throw std::runtime_error("복셀 월드를 못 엶: " + path); + std::string magic, line; + std::getline(f, magic); + if (magic != "VOX2") throw std::runtime_error("복셀 월드 형식이 아님 (VOX2, nusim 으로 다시 만들 것): " + path); + std::getline(f, line); + std::istringstream is(line); + is >> lo[0] >> lo[1] >> lo[2] >> res >> n[0] >> n[1] >> n[2]; + std::getline(f, line); // 점유 칸 수 + std::getline(f, line); + std::istringstream gs(line); + gs >> glo[0] >> glo[1] >> gres >> gn[0] >> gn[1]; + size_t nbytes = (size_t(n[0]) * n[1] * n[2] + 7) / 8; + bits.resize(nbytes); + f.read(reinterpret_cast(bits.data()), std::streamsize(nbytes)); + ground.resize(size_t(gn[0]) * gn[1]); + f.read(reinterpret_cast(ground.data()), std::streamsize(ground.size() * sizeof(float))); + if (!f) throw std::runtime_error("복셀 월드가 잘림: " + path); + } + // 다른 원점으로 옮긴다 (지역 맵: 씬 원점 → 공통 원점). 지면 높이도 z 만큼 + void shift(const double d[3]) { + for (int a = 0; a < 3; ++a) lo[a] += d[a]; + glo[0] += d[0]; + glo[1] += d[1]; + for (auto& g : ground) g += float(d[2]); + } + bool occ(int ix, int iy, int iz) const { + if (ix < 0 || iy < 0 || iz < 0 || ix >= n[0] || iy >= n[1] || iz >= n[2]) return false; + size_t k = (size_t(iy) * n[0] + ix) * n[2] + iz; + return (bits[k >> 3] >> (k & 7)) & 1; + } + // 광선이 지면(높이 지도) 또는 지면 위 점유 칸에 처음 닿는 t. 없으면 -1. + float raycast(const float o[3], const float d[3], float max_t) const { + float tv = raycast_voxels(o, d, max_t); + float tg = raycast_ground(o, d, tv >= 0 ? tv : max_t); + return tg >= 0 ? tg : tv; + } + // 높이 지도와의 교차: 0.2 m 씩 나아가며 (광선 z − 지면) 부호가 바뀌는 곳을 선형으로 좁힌다. 위로 쏘는 빔은 건너뛴다. + float raycast_ground(const float o[3], const float d[3], float max_t, float step = 0.2f) const { + if (ground.empty() || d[2] > 0.05f) return -1; + float prev_h = o[2] - ground_at(o[0], o[1]); + if (std::isnan(prev_h) || prev_h < 0) prev_h = 1e9f; // 시작점 높이를 모르면 첫 유효 칸부터 + for (float t = step; t <= max_t + 1e-4f; t += step) { + float x = o[0] + d[0] * t, y = o[1] + d[1] * t, z = o[2] + d[2] * t; + float g = ground_at(x, y); + if (std::isnan(g)) { prev_h = 1e9f; continue; } + float h = z - g; + if (h <= 0) return prev_h > 1e8f ? t : t - step * h / (h - prev_h); + prev_h = h; + } + return -1; + } + // 광선 o + t·d (d 단위 벡터) 가 처음 점유 칸에 들어가는 t. 없으면 -1. 3D DDA (Amanatides–Woo). + float raycast_voxels(const float o[3], const float d[3], float max_t) const { + if (empty()) return -1; + // 격자 상자와의 교차로 시작점을 당긴다 + float t0 = 0, t1 = max_t; + for (int a = 0; a < 3; ++a) { + float lo_a = float(lo[a]), hi_a = float(lo[a] + n[a] * res); + if (std::abs(d[a]) < 1e-9f) { + if (o[a] < lo_a || o[a] >= hi_a) return -1; + continue; + } + float ta = (lo_a - o[a]) / d[a], tb = (hi_a - o[a]) / d[a]; + if (ta > tb) std::swap(ta, tb); + t0 = std::max(t0, ta); + t1 = std::min(t1, tb); + } + if (t0 > t1) return -1; + float p[3]; + int i[3], step[3]; + float tmax[3], tdelta[3]; + for (int a = 0; a < 3; ++a) { + p[a] = o[a] + d[a] * (t0 + 1e-4f); + i[a] = std::clamp(int(std::floor((p[a] - float(lo[a])) / res)), 0, n[a] - 1); + step[a] = d[a] > 0 ? 1 : -1; + float edge = float(lo[a]) + (i[a] + (d[a] > 0 ? 1 : 0)) * res; + tmax[a] = std::abs(d[a]) < 1e-9f ? 1e30f : t0 + (edge - p[a]) / d[a]; + tdelta[a] = std::abs(d[a]) < 1e-9f ? 1e30f : res / std::abs(d[a]); + } + float t = t0; + while (t <= t1) { + if (occ(i[0], i[1], i[2])) return t; + int a = tmax[0] < tmax[1] ? (tmax[0] < tmax[2] ? 0 : 2) : (tmax[1] < tmax[2] ? 1 : 2); + t = tmax[a]; + tmax[a] += tdelta[a]; + i[a] += step[a]; + if (i[a] < 0 || i[a] >= n[a]) return -1; + } + return -1; + } +}; + +// 시뮬레이터 객체 (다른 차량 등) — 바닥 중심·방위·반 길이·반 폭·높이·속도 (씬 좌표, m/s) +struct SensorBox { + float x = 0, y = 0, z = 0, yaw = 0, half_len = 2.2f, half_wid = 0.9f, height = 1.6f, vx = 0, vy = 0; +}; + +// 광선과 (z 축으로만 돌아간) 상자의 교차 t, 없으면 -1 +inline float ray_box(const float o[3], const float d[3], const SensorBox& b, float max_t) { + float c = std::cos(b.yaw), s = std::sin(b.yaw); + float ox = o[0] - b.x, oy = o[1] - b.y; + float lo_[3] = {c * ox + s * oy, -s * ox + c * oy, o[2] - b.z}; + float ld[3] = {c * d[0] + s * d[1], -s * d[0] + c * d[1], d[2]}; + float mn[3] = {-b.half_len, -b.half_wid, 0}, mx[3] = {b.half_len, b.half_wid, b.height}; + float t0 = 0, t1 = max_t; + for (int a = 0; a < 3; ++a) { + if (std::abs(ld[a]) < 1e-9f) { + if (lo_[a] < mn[a] || lo_[a] > mx[a]) return -1; + continue; + } + float ta = (mn[a] - lo_[a]) / ld[a], tb = (mx[a] - lo_[a]) / ld[a]; + if (ta > tb) std::swap(ta, tb); + t0 = std::max(t0, ta); + t1 = std::min(t1, tb); + if (t0 > t1) return -1; + } + return t0; +} + +struct SensorMount { + std::string name; + float pos[3] = {0, 0, 0}; // 자차 기준 m + float rpy[3] = {0, 0, 0}; // 도 +}; + +struct LidarConfig { + SensorMount mount{"LIDAR_TOP", {0.94f, 0.0f, 1.84f}, {0, 0, 0}}; // nuScenes LIDAR_TOP 근사 + int channels = 32; // Velodyne HDL-32E + float vfov_lo = -30.67f, vfov_hi = 10.67f; // 도 + float h_res = 0.33f; // 도 (20 Hz 회전 1바퀴 약 1,090 방위) + float range = 70.0f; // m + float noise = 0.02f; // 거리 잡음 σ (m) + float hz = 20.0f; +}; + +struct RadarConfig { + SensorMount mount; + float hfov = 120.0f, vfov = 10.0f; // 도 (Continental ARS 408 근거리 모드 근사) + float range = 120.0f; // m + float az_res = 1.0f; // 도 + int el_beams = 3; + int max_points = 125; // nuScenes 레이더 한 장 최대 점 수 근사 + float min_height = 0.25f; // 자차 지면 위 이보다 낮은 반사(노면)는 버린다 + float noise_range = 0.1f, noise_vel = 0.1f; + float hz = 13.0f; +}; + +struct EgoState { // 센서가 쓰는 자차 상태 (pitch + = 앞이 내려감, roll + = 왼쪽이 올라감 — 라디안) + float x = 0, y = 0, z = 0, yaw = 0, v = 0, yaw_rate = 0, pitch = 0, roll = 0; +}; + +// 자차를 노면에 앉힌다: 네 바퀴 자리의 지면 높이(복셀 월드의 지면 격자)로 z·pitch·roll 을 정한다. +// 시뮬레이터의 차는 평면 위를 달리지만 실제 길은 기울어 있다 — scene-0402 실측 pitch 1.8°·roll −1.1° 를 무시하면 +// 낮게 내려 쏘는 라이다 빔의 노면 거리가 20% 넘게 짧아졌다 (2026-10-09). 바퀴 자리에 지면이 없으면 그대로 둔다. +inline EgoState settle_on_ground(const VoxelWorld& w, EgoState e, float wheelbase, float half_track) { + if (w.empty()) return e; + float c = std::cos(e.yaw), s = std::sin(e.yaw), z[4]; + const float pts[4][2] = {{0, half_track}, {0, -half_track}, {wheelbase, half_track}, {wheelbase, -half_track}}; + for (int k = 0; k < 4; ++k) { + z[k] = w.ground_at(e.x + c * pts[k][0] - s * pts[k][1], e.y + s * pts[k][0] + c * pts[k][1]); + if (std::isnan(z[k])) return e; + } + float rear = 0.5f * (z[0] + z[1]), front = 0.5f * (z[2] + z[3]); + float left = 0.5f * (z[0] + z[2]), right = 0.5f * (z[1] + z[3]); + e.z = rear; + e.pitch = std::atan2(rear - front, wheelbase); + e.roll = std::atan2(left - right, 2 * half_track); + return e; +} + +struct SensorPose { + float o[3]; + float R[9]; // 센서 → 씬 (행 우선) + void dir(float sx, float sy, float sz, float out[3]) const { + for (int r = 0; r < 3; ++r) out[r] = R[r * 3] * sx + R[r * 3 + 1] * sy + R[r * 3 + 2] * sz; + } + // 씬 점 → 센서 좌표 (Rᵀ (p − o)) + void to_sensor(const float p[3], float out[3]) const { + float q[3] = {p[0] - o[0], p[1] - o[1], p[2] - o[2]}; + for (int c = 0; c < 3; ++c) out[c] = R[c] * q[0] + R[3 + c] * q[1] + R[6 + c] * q[2]; + } +}; + +inline SensorPose sensor_pose(const EgoState& e, const SensorMount& m) { + const float k = 3.14159265f / 180.0f; + float r = m.rpy[0] * k, p = m.rpy[1] * k, y = m.rpy[2] * k; + float cr = std::cos(r), sr = std::sin(r), cp = std::cos(p), sp = std::sin(p), cy = std::cos(y), sy = std::sin(y); + // 장착 자세 Rz(y)·Ry(p)·Rx(r), 그다음 자차 방위 Rz(yaw) + float M[9] = {cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr, + sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr, + -sp, cp * sr, cp * cr}; + // 자차 자세 E = Rz(yaw)·Ry(pitch)·Rx(roll) (pitch + = 앞이 내려감 → y 축 둘레 +), 센서 = E·M + float cy2 = std::cos(e.yaw), sy2 = std::sin(e.yaw), cp2 = std::cos(e.pitch), sp2 = std::sin(e.pitch), + cr2 = std::cos(e.roll), sr2 = std::sin(e.roll); + float E[9] = {cy2 * cp2, cy2 * sp2 * sr2 - sy2 * cr2, cy2 * sp2 * cr2 + sy2 * sr2, + sy2 * cp2, sy2 * sp2 * sr2 + cy2 * cr2, sy2 * sp2 * cr2 - cy2 * sr2, + -sp2, cp2 * sr2, cp2 * cr2}; + SensorPose s; + for (int r_ = 0; r_ < 3; ++r_) + for (int c = 0; c < 3; ++c) + s.R[r_ * 3 + c] = E[r_ * 3] * M[c] + E[r_ * 3 + 1] * M[3 + c] + E[r_ * 3 + 2] * M[6 + c]; + for (int r_ = 0; r_ < 3; ++r_) s.o[r_] = (r_ == 0 ? e.x : r_ == 1 ? e.y : e.z) + E[r_ * 3] * m.pos[0] + E[r_ * 3 + 1] * m.pos[1] + E[r_ * 3 + 2] * m.pos[2]; + return s; +} + +struct LidarPoint { + float p[3]; // 씬 좌표 + float s[3]; // 센서 좌표 + float range; + int ring; + int object; // -1 = 정적 월드, 아니면 객체 번호 +}; + +// 첫 부딪힘: 월드와 객체 중 가까운 쪽. 반환 t (없으면 -1), obj 에 객체 번호 +inline float cast(const VoxelWorld& w, const std::vector& boxes, const float o[3], const float d[3], + float max_t, int& obj) { + float t = w.raycast(o, d, max_t); + obj = -1; + for (size_t k = 0; k < boxes.size(); ++k) { + float tb = ray_box(o, d, boxes[k], max_t); + if (tb >= 0 && (t < 0 || tb < t)) t = tb, obj = int(k); + } + return t; +} + +// 라이다 한 바퀴. 채널마다 스레드로 나눈다 (32 × 1,090 광선, 광선당 최대 350 칸). +inline std::vector lidar_scan_at(const VoxelWorld& w, const std::vector& boxes, const LidarConfig& c, + const SensorPose& sp, uint32_t seed = 1, int threads = 0); + +inline std::vector lidar_scan(const VoxelWorld& w, const std::vector& boxes, const LidarConfig& c, + const EgoState& e, uint32_t seed = 1, int threads = 0) { + return lidar_scan_at(w, boxes, c, sensor_pose(e, c.mount), seed, threads); +} + +// 센서 자세를 직접 주는 판 (실측 센서 자세와 비교할 때) +inline std::vector lidar_scan_at(const VoxelWorld& w, const std::vector& boxes, const LidarConfig& c, + const SensorPose& sp, uint32_t seed, int threads) { + int n_az = std::max(1, int(std::round(360.0f / c.h_res))); + if (threads <= 0) threads = std::max(1u, std::thread::hardware_concurrency()); + threads = std::min(threads, c.channels); + std::vector> part(threads); + auto work = [&](int tid) { + std::mt19937 rng(seed * 7919u + uint32_t(tid)); + std::normal_distribution nz(0.0f, c.noise); + const float k = 3.14159265f / 180.0f; + for (int ch = tid; ch < c.channels; ch += threads) { + float el = (c.channels == 1 ? 0.0f : c.vfov_lo + (c.vfov_hi - c.vfov_lo) * ch / float(c.channels - 1)) * k; + for (int a = 0; a < n_az; ++a) { + float az = (a * 360.0f / n_az) * k; + float sd[3] = {std::cos(el) * std::cos(az), std::cos(el) * std::sin(az), std::sin(el)}, d[3]; + sp.dir(sd[0], sd[1], sd[2], d); + int obj; + float t = cast(w, boxes, sp.o, d, c.range, obj); + if (t < 0) continue; + if (c.noise > 0) t = std::max(0.0f, t + nz(rng)); + LidarPoint lp; + for (int r = 0; r < 3; ++r) lp.p[r] = sp.o[r] + d[r] * t, lp.s[r] = sd[r] * t; + lp.range = t; + lp.ring = ch; + lp.object = obj; + part[tid].push_back(lp); + } + } + }; + std::vector ts; + for (int i = 1; i < threads; ++i) ts.emplace_back(work, i); + work(0); + for (auto& t : ts) t.join(); + std::vector out; + for (auto& p : part) out.insert(out.end(), p.begin(), p.end()); + return out; +} + +struct RadarPoint { + float p[3]; // 씬 좌표 + float s[3]; // 센서 좌표 + float range, azimuth, elevation; // m, 도 + float vr; // 반경 방향 상대 속도 (m/s, + = 멀어짐) — 도플러 + float v_comp[2]; // 자차 운동을 보정한 대상 속도 (센서 좌표 x, y) — nuScenes vx_comp, vy_comp 와 같은 뜻 + float rcs; // 반사 세기 근사 (dBsm): 객체 10, 세로 구조물 5, 그 밖 0 + int object; +}; + +// 레이더 한 번. 방위마다 가장 가까운 반사 하나 (고도 빔 중), 노면 반사는 버리고, 가까운 순으로 max_points 개. +inline std::vector radar_scan(const VoxelWorld& w, const std::vector& boxes, const RadarConfig& c, + const EgoState& e, uint32_t seed = 1) { + SensorPose sp = sensor_pose(e, c.mount); + std::mt19937 rng(seed); + std::normal_distribution nr(0.0f, c.noise_range), nv(0.0f, c.noise_vel); + const float k = 3.14159265f / 180.0f; + // 센서 속도 (씬 좌표): 자차 속도 + 요레이트 × 장착 위치 + float ce = std::cos(e.yaw), se = std::sin(e.yaw); + float rx = sp.o[0] - e.x, ry = sp.o[1] - e.y; + float vs[2] = {e.v * ce - e.yaw_rate * ry, e.v * se + e.yaw_rate * rx}; + std::vector out; + int n_az = std::max(1, int(std::round(c.hfov / c.az_res))); + for (int a = 0; a <= n_az; ++a) { + float az = (-0.5f * c.hfov + a * c.az_res) * k; + RadarPoint best{}; + best.range = -1; + int vertical = 0; + for (int b = 0; b < c.el_beams; ++b) { + float el = (c.el_beams == 1 ? 0.0f : -0.5f * c.vfov + c.vfov * b / float(c.el_beams - 1)) * k; + float sd[3] = {std::cos(el) * std::cos(az), std::cos(el) * std::sin(az), std::sin(el)}, d[3]; + sp.dir(sd[0], sd[1], sd[2], d); + int obj; + float t = cast(w, boxes, sp.o, d, c.range, obj); + if (t < 0) continue; + float hz = sp.o[2] + d[2] * t; + if (obj < 0 && hz < e.z + c.min_height) continue; // 노면 + ++vertical; + if (best.range >= 0 && t >= best.range) continue; + best.range = t; + best.object = obj; + best.elevation = el / k; + for (int r = 0; r < 3; ++r) best.p[r] = sp.o[r] + d[r] * t, best.s[r] = sd[r] * t; + } + if (best.range < 0) continue; + best.azimuth = az / k; + float tv[2] = {0, 0}; + if (best.object >= 0) tv[0] = boxes[best.object].vx, tv[1] = boxes[best.object].vy; + float u[3] = {best.p[0] - sp.o[0], best.p[1] - sp.o[1], best.p[2] - sp.o[2]}; + float un = std::sqrt(u[0] * u[0] + u[1] * u[1] + u[2] * u[2]); + best.vr = ((tv[0] - vs[0]) * u[0] + (tv[1] - vs[1]) * u[1]) / std::max(un, 1e-6f) + nv(rng); + float tv3[3] = {tv[0], tv[1], 0}, tvs[3]; + SensorPose rot = sp; // 회전만 (속도는 위치가 아니다) + rot.o[0] = rot.o[1] = rot.o[2] = 0; + rot.to_sensor(tv3, tvs); + best.v_comp[0] = tvs[0]; + best.v_comp[1] = tvs[1]; + best.range = std::max(0.0f, best.range + nr(rng)); + best.rcs = best.object >= 0 ? 10.0f : (vertical >= 2 ? 5.0f : 0.0f); + out.push_back(best); + } + std::sort(out.begin(), out.end(), [](const RadarPoint& a, const RadarPoint& b) { + return a.rcs != b.rcs ? a.rcs > b.rcs : a.range < b.range; + }); + if (int(out.size()) > c.max_points) out.resize(c.max_points); + return out; +} + +// nuScenes 라이다 .pcd.bin 과 같은 형식: float32 (x, y, z, 세기, 링) × N, 센서 좌표. 세기는 없으므로 거리로 근사 (0~255). +inline void write_lidar_bin(const std::string& path, const std::vector& pts, float range) { + std::ofstream f(path, std::ios::binary); + for (const auto& p : pts) { + float v[5] = {p.s[0], p.s[1], p.s[2], 255.0f * (1.0f - std::min(p.range / range, 1.0f)), float(p.ring)}; + f.write(reinterpret_cast(v), sizeof(v)); + } +} + +// 레이더 검출 (센서 좌표) — 한 줄에 하나: x y z range azimuth elevation vr vx_comp vy_comp rcs object +inline void write_radar_txt(const std::string& path, const std::vector& pts) { + std::ofstream f(path); + f << "# x y z range azimuth_deg elevation_deg vr vx_comp vy_comp rcs object (센서 좌표: x 앞, y 왼쪽, z 위)\n"; + for (const auto& p : pts) + f << p.s[0] << ' ' << p.s[1] << ' ' << p.s[2] << ' ' << p.range << ' ' << p.azimuth << ' ' << p.elevation << ' ' + << p.vr << ' ' << p.v_comp[0] << ' ' << p.v_comp[1] << ' ' << p.rcs << ' ' << p.object << '\n'; +} + +} // namespace vkgs diff --git a/src/vkgs/src/serve.hpp b/src/vkgs/src/serve.hpp new file mode 100644 index 0000000000000000000000000000000000000000..3ca96378ebe7be42735b8a727e5d791d7cb3cd23 --- /dev/null +++ b/src/vkgs/src/serve.hpp @@ -0,0 +1,132 @@ +// closed-loop 서버 프로토콜 (vkgs_viewer --serve DIR) — 줄 단위 명령·응답 (헤더 전용, 창 없이 시험: tests/test_serve_cpp.py). +// +// HUGSIM(arXiv 2412.01718) 처럼 시뮬레이터와 주행 알고리즘을 다른 프로세스로 두고, 같은 기계에서는 이름 있는 파이프 +// (FIFO) 두 개로 말한다: DIR/cmd (Python → 서버), DIR/resp (서버 → Python). 둘 다 cmd 를 먼저 연다 (교착 방지). +// 관측(6캠 RGB)은 파이프로 보내지 않고 공유 메모리 파일(/dev/shm/vkgs_/.rgb, H×W×3 uint8)에 쓴다. +// +// 명령 = 이름 + key=value 들 (공백 구분, 값에 공백 없음). 응답 = 'ok key=value ...' 또는 'err 설명'. +// hello 서버 정보: 해상도·카메라·차량 치수·센서 +// reset x= y= yaw= [z=] [v=] [frame=N] [render=1] [sensors=0] +// 자차를 그 자세로 (frame 이면 리그의 기록 자차 포즈). 상태 응답 +// step dt= (throttle= brake= steer= | accel= delta=) [render=0] [sensors=0] +// dt 초 적분 (0.01 s 물리 스텝, 충돌·연석 포함). accel m/s², delta rad +// render [sensors=0] 지금 자세에서 관측만 다시 +// actors boxes=x,y,yaw,half_len,half_wid;... 다른 차량 바닥 사각형 (충돌·센서용, 그리기는 아직 없음). 빈 값이면 지움 +// state 상태만 +// close 끝 ('ok bye') +// 상태 응답 키: t x y z yaw v yaw_rate accel delta contact(0 없음 1 장애물 2 연석 3 차량) contact_n blocked collisions +// on_road log_dev ground_z pitch roll seg step_ms render_ms obs(관측 디렉터리, 안 그렸으면 -) +// 줄 끝 '\n'. 같은 줄 안의 같은 키는 뒤 것이 이긴다. +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace vkgs { + +constexpr int kServeProtocolVersion = 1; + +struct ServeCommand { + std::string name; + std::map kv; + + bool has(const std::string& k) const { return kv.count(k) > 0; } + const std::string& str(const std::string& k) const { + auto it = kv.find(k); + if (it == kv.end()) throw std::runtime_error(name + ": " + k + "= 가 필요함"); + return it->second; + } + float num(const std::string& k) const { + const std::string& s = str(k); + size_t used = 0; + float v = 0; + try { + v = std::stof(s, &used); + } catch (const std::exception&) { + used = 0; + } + if (used != s.size()) throw std::runtime_error(name + ": " + k + "=" + s + " 는 수가 아님"); + return v; + } + float num(const std::string& k, float def) const { return has(k) ? num(k) : def; } + bool flag(const std::string& k, bool def) const { + if (!has(k)) return def; + const std::string& s = str(k); + if (s == "1" || s == "true" || s == "on") return true; + if (s == "0" || s == "false" || s == "off") return false; + throw std::runtime_error(name + ": " + k + "=" + s + " 는 0|1"); + } +}; + +// 한 줄 → 명령. 빈 줄·'#' 주석은 이름이 빈 명령. key=value 가 아닌 토큰은 오류. +inline ServeCommand parse_command(const std::string& line) { + ServeCommand c; + std::istringstream ss(line); + std::string tok; + if (!(ss >> c.name) || c.name[0] == '#') { + c.name.clear(); + return c; + } + while (ss >> tok) { + auto eq = tok.find('='); + if (eq == std::string::npos || eq == 0) throw std::runtime_error(c.name + ": key=value 가 아닌 토큰 '" + tok + "'"); + c.kv[tok.substr(0, eq)] = tok.substr(eq + 1); + } + return c; +} + +// 'x,y,yaw,hl,hw;x,y,...' → 상자 값들 (5개씩). 형식이 틀리면 던진다. +inline std::vector> parse_boxes(const std::string& s) { + std::vector> out; + std::stringstream ss(s); + std::string item; + while (std::getline(ss, item, ';')) { + if (item.empty()) continue; + std::array b{}; + std::stringstream is(item); + std::string v; + int k = 0; + while (std::getline(is, v, ',')) { + if (k >= 5) throw std::runtime_error("actors: 상자 값이 5개를 넘음 '" + item + "'"); + b[k++] = std::stof(v); + } + if (k != 5) throw std::runtime_error("actors: 상자는 x,y,yaw,half_len,half_wid '" + item + "'"); + out.push_back(b); + } + return out; +} + +// 응답 줄 만들기 — 키 순서를 지킨다 +struct ServeReply { + std::vector> kv; + ServeReply& add(const std::string& k, const std::string& v) { + kv.emplace_back(k, v); + return *this; + } + ServeReply& add(const std::string& k, double v, int prec = 6) { + char b[64]; + std::snprintf(b, sizeof(b), "%.*f", prec, v); + return add(k, std::string(b)); + } + ServeReply& add(const std::string& k, int v) { return add(k, std::to_string(v)); } + std::string line() const { + std::string s = "ok"; + for (const auto& [k, v] : kv) s += " " + k + "=" + (v.empty() ? "-" : v); + return s + "\n"; + } +}; + +inline std::string serve_error(const std::string& msg) { + std::string m = msg; + for (char& ch : m) + if (ch == '\n' || ch == '\r') ch = ' '; + return "err " + m + "\n"; +} + +} // namespace vkgs diff --git a/src/vkgs/src/shader_reflect.cpp b/src/vkgs/src/shader_reflect.cpp new file mode 100644 index 0000000000000000000000000000000000000000..40e06e25bc2e763b809add05bff647f3a43f6427 --- /dev/null +++ b/src/vkgs/src/shader_reflect.cpp @@ -0,0 +1,88 @@ +#include "shader_reflect.hpp" + +#include +#include + +#include "spirv_reflect.h" + +namespace vkgs { + +bool ShaderInfo::writes_output(uint32_t location) const { + return std::find(outputs.begin(), outputs.end(), location) != outputs.end(); +} + +ShaderInfo reflect_spirv(const std::vector& spirv, const std::string& name) { + SpvReflectShaderModule m; + if (spvReflectCreateShaderModule(spirv.size(), spirv.data(), &m) != SPV_REFLECT_RESULT_SUCCESS) + throw std::runtime_error("SPIR-V 리플렉션 실패: " + name); + ShaderInfo info; + info.name = name; + info.stage = static_cast(m.shader_stage); + + uint32_t n = 0; + spvReflectEnumerateDescriptorBindings(&m, &n, nullptr); + std::vector bs(n); + spvReflectEnumerateDescriptorBindings(&m, &n, bs.data()); + for (auto* b : bs) { + ShaderBinding sb; + sb.set = b->set; + sb.binding = b->binding; + sb.type = static_cast(b->descriptor_type); + sb.name = b->type_description && b->type_description->type_name ? b->type_description->type_name + : (b->name ? b->name : ""); + info.bindings.push_back(sb); + } + + n = 0; + spvReflectEnumeratePushConstantBlocks(&m, &n, nullptr); + std::vector pcs(n); + spvReflectEnumeratePushConstantBlocks(&m, &n, pcs.data()); + for (auto* pc : pcs) + for (uint32_t k = 0; k < pc->member_count; ++k) { + const auto& mem = pc->members[k]; + info.push_members.push_back({mem.name ? mem.name : "", mem.absolute_offset, mem.size}); + info.push_size = std::max(info.push_size, mem.absolute_offset + mem.size); + } + + if (info.stage == VK_SHADER_STAGE_FRAGMENT_BIT) { + n = 0; + spvReflectEnumerateOutputVariables(&m, &n, nullptr); + std::vector outs(n); + spvReflectEnumerateOutputVariables(&m, &n, outs.data()); + for (auto* o : outs) + if (o->location != UINT32_MAX && !(o->decoration_flags & SPV_REFLECT_DECORATION_BUILT_IN)) + info.outputs.push_back(o->location); + std::sort(info.outputs.begin(), info.outputs.end()); + } + if (info.stage == VK_SHADER_STAGE_COMPUTE_BIT && m.entry_point_count > 0) { + const auto& ls = m.entry_points[0].local_size; + info.local_size[0] = std::max(1u, ls.x); + info.local_size[1] = std::max(1u, ls.y); + info.local_size[2] = std::max(1u, ls.z); + } + spvReflectDestroyShaderModule(&m); + return info; +} + +const char* descriptor_type_name(VkDescriptorType t) { + switch (t) { + case VK_DESCRIPTOR_TYPE_STORAGE_BUFFER: return "STORAGE_BUFFER"; + case VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER: return "UNIFORM_BUFFER"; + case VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER: return "COMBINED_IMAGE_SAMPLER"; + case VK_DESCRIPTOR_TYPE_STORAGE_IMAGE: return "STORAGE_IMAGE"; + case VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE: return "SAMPLED_IMAGE"; + case VK_DESCRIPTOR_TYPE_SAMPLER: return "SAMPLER"; + default: return "OTHER"; + } +} + +const char* stage_name(VkShaderStageFlagBits s) { + switch (s) { + case VK_SHADER_STAGE_VERTEX_BIT: return "vertex"; + case VK_SHADER_STAGE_FRAGMENT_BIT: return "fragment"; + case VK_SHADER_STAGE_COMPUTE_BIT: return "compute"; + default: return "other"; + } +} + +} // namespace vkgs diff --git a/src/vkgs/src/shader_reflect.hpp b/src/vkgs/src/shader_reflect.hpp new file mode 100644 index 0000000000000000000000000000000000000000..fb1f0c9471ee0b65a582ef023e87278cfc2dfce3 --- /dev/null +++ b/src/vkgs/src/shader_reflect.hpp @@ -0,0 +1,49 @@ +// 셰이더 리플렉션 — 컴파일된 SPIR-V 에서 인터페이스를 읽는다 (SPIRV-Reflect, Khronos, Apache-2.0). +// +// 코드에 박아 두던 셰이더 정보를 셰이더 자신에게서 얻는다: +// - push constant 크기 → 전역 파이프라인 레이아웃의 push 범위 (모든 셰이더 중 최댓값) +// - descriptor binding → bindless 레이아웃(set 0, binding 0~5)과 종류가 맞는지 시작할 때 검사 +// - 프래그먼트 출력 위치 → 거리 누적(출력 1)을 쓰는 파이프라인만 그 첨부에 쓰게 (예전엔 셰이더 이름으로 갈랐다) +// - 컴퓨트 워크그룹 크기 → 디스패치 그룹 수 (예전엔 256, 8 을 박아 두었다) +// 렌더 그래프가 패스마다 어떤 리소스 종류를 쓰는지 알아내는 데에도 쓴다 (--reflect 로 JSON 덤프). +#pragma once + +#include + +#include +#include +#include + +namespace vkgs { + +struct ShaderBinding { + uint32_t set = 0, binding = 0; + VkDescriptorType type = VK_DESCRIPTOR_TYPE_MAX_ENUM; + std::string name; // 블록·변수 이름 (예: Splats, gTex2D) +}; + +struct PushMember { + std::string name; + uint32_t offset = 0, size = 0; +}; + +struct ShaderInfo { + std::string name; // 파일 이름 (예: gs_splat.vert) + VkShaderStageFlagBits stage = VK_SHADER_STAGE_VERTEX_BIT; + uint32_t push_size = 0; // push constant 블록이 쓰는 바이트 (마지막 멤버 끝) + std::vector push_members; + std::vector bindings; + std::vector outputs; // 프래그먼트 출력 location + uint32_t local_size[3] = {1, 1, 1}; + + bool writes_output(uint32_t location) const; +}; + +// 실패하면 std::runtime_error. +ShaderInfo reflect_spirv(const std::vector& spirv, const std::string& name); + +// 사람이 읽을 이름 (STORAGE_BUFFER 등) +const char* descriptor_type_name(VkDescriptorType t); +const char* stage_name(VkShaderStageFlagBits s); + +} // namespace vkgs diff --git a/src/vkgs/src/viewer.cpp b/src/vkgs/src/viewer.cpp new file mode 100644 index 0000000000000000000000000000000000000000..65bae42b57d74f86c62f59f78485a3cc77647d8b --- /dev/null +++ b/src/vkgs/src/viewer.cpp @@ -0,0 +1,5290 @@ +// vkgs_viewer — 3DGS .ply 를 Vulkan 으로 그린다 (roadmap 2a: 빌보드 경로). +// +// 프레임 = 전처리(컴퓨트: 투영·2D 공분산·SH 색·정렬 키) +// → bitonic 정렬(컴퓨트) → 스플랫 쿼드(그래픽, 인다이렉트) → 맵 선 → blit +// +// 수식/상수는 gsplat 1.4.0 과 같다 (shaders/gs_common.glsl). 그래서 같은 카메라에서 +// 학습기 렌더와 픽셀 단위로 비교할 수 있다 (nusim render compare). +// +// 모드: +// 오프스크린 --cams cameras.txt --out DIR 카메라 파일의 뷰를 전부 PNG 로 +// 창 (기본) 자유 카메라 / 6캠 추종 / 궤적 재생 / BEV +// +// 카메라 파일과 맵 선 파일은 Python 쪽(nusim render export)이 만든다. 좌표는 전부 +// 씬 원점 기준(ply 의 nusim_origin 을 뺀 값), 카메라는 OpenCV 관례다. + +#include +#define GLFW_INCLUDE_NONE +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // mkfifo (--serve) +#include +#include // environ +#include +#include +#include +#include +#include + +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include "stb_image_write.h" + +#include "nlohmann/json.hpp" +// Tracy 프로파일러 (third_party/tracy, BSD-3). VKGS_TRACY 로 빌드하면 TRACY_ENABLE + TRACY_ON_DEMAND — +// 프로파일러(build/tracy-profiler/tracy-profiler 또는 tracy-capture)가 붙어 있을 때만 기록한다. +// 끄고 빌드하면 아래 매크로는 모두 빈 것이 된다. +#include "tracy/Tracy.hpp" +#include "tracy/TracyVulkan.hpp" + +#include "imgui.h" +#include "imgui_impl_glfw.h" +#include "imgui_impl_vulkan.h" + +#include "drive.hpp" +#include "render_graph.hpp" +#include "scene_objects.hpp" +#include "segment_map.hpp" +#include "sensors.hpp" +#include "serve.hpp" +#include "shader_reflect.hpp" +#include "gltf.hpp" +#include "ply.hpp" + +#ifndef VKGS_SPIRV_DIR +#define VKGS_SPIRV_DIR "shaders" +#endif + +#define VK(expr) \ + do { \ + VkResult _r = (expr); \ + if (_r != VK_SUCCESS) \ + throw std::runtime_error(std::string(#expr) + " -> " + std::to_string(_r)); \ + } while (0) + +namespace fs = std::filesystem; + +namespace { + +// ============================================================== 작은 선형대수 +// 행 우선 4x4. GPU 로 보낼 때만 열 우선으로 바꾼다 (GLSL mat4 는 열 우선). +struct Mat4 { + float m[16]; + static Mat4 identity() { + Mat4 r{}; + r.m[0] = r.m[5] = r.m[10] = r.m[15] = 1; + return r; + } + float& at(int r, int c) { return m[r * 4 + c]; } + float at(int r, int c) const { return m[r * 4 + c]; } + Mat4 operator*(const Mat4& b) const { + Mat4 r{}; + for (int i = 0; i < 4; ++i) + for (int j = 0; j < 4; ++j) + for (int k = 0; k < 4; ++k) r.at(i, j) += at(i, k) * b.at(k, j); + return r; + } + void to_column_major(float* out) const { + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) out[c * 4 + r] = at(r, c); + } +}; + +struct Vec3 { + float x = 0, y = 0, z = 0; + Vec3 operator+(const Vec3& o) const { return {x + o.x, y + o.y, z + o.z}; } + Vec3 operator-(const Vec3& o) const { return {x - o.x, y - o.y, z - o.z}; } + Vec3 operator*(float s) const { return {x * s, y * s, z * s}; } +}; +Vec3 cross(Vec3 a, Vec3 b) { return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; } +float dot(Vec3 a, Vec3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; } +Vec3 normalize(Vec3 a) { + float n = std::sqrt(dot(a, a)); + return n > 0 ? a * (1.0f / n) : a; +} + +// 카메라 회전(열 = 카메라 x/y/z 축의 world 방향) + 위치 → w2c +Mat4 w2c_from(Vec3 right, Vec3 down, Vec3 fwd, Vec3 pos) { + Mat4 m = Mat4::identity(); + Vec3 axes[3] = {right, down, fwd}; + for (int r = 0; r < 3; ++r) { + m.at(r, 0) = axes[r].x; + m.at(r, 1) = axes[r].y; + m.at(r, 2) = axes[r].z; + m.at(r, 3) = -dot(axes[r], pos); + } + return m; +} + +// ============================================================== 입력 파일 +struct CamView { // 카메라 파일 한 줄 + int frame = 0; + int64_t ts = 0; + std::string name; + int width = 0, height = 0; + float fx = 0, fy = 0, cx = 0, cy = 0; + Mat4 w2c = Mat4::identity(); +}; + +struct EgoPose { + int frame = 0; + int64_t ts = 0; + Vec3 pos; + float yaw = 0; // 전역 x 축 기준, 반시계 + +}; + +struct Agent { // 다른 차량 한 대의 한 프레임 (nuScenes 3D 박스) + int inst = 0; + float x = 0, y = 0, z = 0, yaw = 0; // z = 박스 바닥 + float l = 4.5f, w = 1.8f, h = 1.5f; + std::string category; +}; + +struct CamRig { // 카메라 → 자차 (직접 운전 때 카메라를 차에 붙인다) + std::string name; + int width = 0, height = 0; + float fx = 0, fy = 0, cx = 0, cy = 0; + Mat4 cam2ego = Mat4::identity(); +}; + +struct Rig { + std::vector rigs; + std::vector views; + std::map> agents; // 프레임 → 다른 차량들 + std::vector ego; + std::vector cam_names; // 등장 순서 (= nusim.CAMERAS) + int n_frames = 0; + const CamRig* rig_of(const std::string& name) const { + for (const auto& r : rigs) + if (r.name == name) return &r; + return nullptr; + } + const CamView* find(int frame, const std::string& name) const { + for (const auto& v : views) + if (v.frame == frame && v.name == name) return &v; + return nullptr; + } +}; + +// 형식 (nusim/render/export.py): +// C frame ts name W H fx fy cx cy w2c(16, 행 우선) +// E frame ts x y z qw qx qy qz +Rig load_rig(const std::string& path) { + std::ifstream f(path); + if (!f) throw std::runtime_error("카메라 파일을 열 수 없음: " + path); + Rig rig; + std::string line; + while (std::getline(f, line)) { + if (line.empty() || line[0] == '#') continue; + std::istringstream ss(line); + char tag; + ss >> tag; + if (tag == 'C') { + CamView v; + ss >> v.frame >> v.ts >> v.name >> v.width >> v.height >> v.fx >> v.fy >> v.cx >> v.cy; + for (float& x : v.w2c.m) ss >> x; + if (std::find(rig.cam_names.begin(), rig.cam_names.end(), v.name) == rig.cam_names.end()) + rig.cam_names.push_back(v.name); + rig.n_frames = std::max(rig.n_frames, v.frame + 1); + rig.views.push_back(v); + } else if (tag == 'A') { + int fr; + Agent a; + ss >> fr >> a.inst >> a.x >> a.y >> a.z >> a.yaw >> a.l >> a.w >> a.h >> a.category; + rig.agents[fr].push_back(a); + } else if (tag == 'R') { + CamRig cr; + ss >> cr.name >> cr.width >> cr.height >> cr.fx >> cr.fy >> cr.cx >> cr.cy; + for (float& x : cr.cam2ego.m) ss >> x; + rig.rigs.push_back(cr); + } else if (tag == 'E') { + EgoPose e; + float qw, qx, qy, qz; + ss >> e.frame >> e.ts >> e.pos.x >> e.pos.y >> e.pos.z >> qw >> qx >> qy >> qz; + e.yaw = std::atan2(2 * (qw * qz + qx * qy), 1 - 2 * (qy * qy + qz * qz)); + rig.ego.push_back(e); + } + } + return rig; +} + +struct LineVertex { // line.vert 가 float 7개로 읽는다 (vertex pulling) + float pos[3]; + float color[4]; +}; +static_assert(sizeof(LineVertex) == 7 * sizeof(float), "line.vert 정점 크기"); + +// 형식: L r g b a n x y z x y z ... (폴리라인 하나) +std::vector load_lines(const std::string& path) { + std::ifstream f(path); + if (!f) throw std::runtime_error("선 파일을 열 수 없음: " + path); + std::vector out; + std::string line; + while (std::getline(f, line)) { + if (line.empty() || line[0] != 'L') continue; + std::istringstream ss(line.substr(1)); + float c[4]; + int n; + ss >> c[0] >> c[1] >> c[2] >> c[3] >> n; + std::vector> pts(n); + for (auto& p : pts) ss >> p[0] >> p[1] >> p[2]; + for (int i = 0; i + 1 < n; ++i) { // 선분 목록 (LINE_LIST) + for (int k : {i, i + 1}) { + LineVertex v{}; + std::memcpy(v.pos, pts[k].data(), sizeof(v.pos)); + std::memcpy(v.color, c, sizeof(v.color)); + out.push_back(v); + } + } + } + return out; +} + +std::vector read_file(const std::string& path) { + std::ifstream f(path, std::ios::ate | std::ios::binary); + if (!f) throw std::runtime_error("읽을 수 없음: " + path); + size_t n = static_cast(f.tellg()); + std::vector buf(n); + f.seekg(0); + f.read(buf.data(), static_cast(n)); + return buf; +} + +// ============================================================== 렌더 카메라 +struct RenderCam { + Mat4 w2c = Mat4::identity(); + float fx = 0, fy = 0, cx = 0, cy = 0; + float near = 0.2f, far = 2000.0f; // 학습기(nusim.recon.train.render)와 같은 값 + float clip_z = 1e30f; // 이보다 높은(씬 원점 기준 z) 가우시안은 그리지 않는다 + bool show_obj = false; // 객체 에셋(자차) 그리기 — 자차 카메라 시점에서는 끈다 + bool show_vehicles = true; // glTF 다른 차량 + Mat4 obj_xform = Mat4::identity(); // 에셋 로컬 → 씬 + bool sensor = false; // 자차 카메라 센서 시점 — 카메라 모드(깊이 · 시맨틱 · 흑백)는 여기에만. 메인 화면 · BEV 는 늘 RGB +}; + +// nusim.data.rig.CameraPose.projection 과 같은 Vulkan clip 투영 (y 뒤집기 없음) +Mat4 projection(const RenderCam& c, int w, int h) { + Mat4 p{}; + p.at(0, 0) = 2 * c.fx / w; + p.at(1, 1) = 2 * c.fy / h; + p.at(0, 2) = 2 * c.cx / w - 1; + p.at(1, 2) = 2 * c.cy / h - 1; + p.at(2, 2) = c.far / (c.far - c.near); + p.at(2, 3) = -c.far * c.near / (c.far - c.near); + p.at(3, 2) = 1; + return p; +} + +Vec3 cam_position(const Mat4& w2c) { + // c = -Rᵀ t + Vec3 t{w2c.at(0, 3), w2c.at(1, 3), w2c.at(2, 3)}; + return {-(w2c.at(0, 0) * t.x + w2c.at(1, 0) * t.y + w2c.at(2, 0) * t.z), + -(w2c.at(0, 1) * t.x + w2c.at(1, 1) * t.y + w2c.at(2, 1) * t.z), + -(w2c.at(0, 2) * t.x + w2c.at(1, 2) * t.y + w2c.at(2, 2) * t.z)}; +} + +// 강체 변환의 역 (R, t) → (Rᵀ, -Rᵀt) +Mat4 rigid_inverse(const Mat4& m) { + Mat4 r = Mat4::identity(); + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) r.at(i, j) = m.at(j, i); + for (int i = 0; i < 3; ++i) + r.at(i, 3) = -(r.at(i, 0) * m.at(0, 3) + r.at(i, 1) * m.at(1, 3) + r.at(i, 2) * m.at(2, 3)); + return r; +} + +// 자차 포즈 (평지 가정: yaw 만) → ego2world +Mat4 ego_matrix(float x, float y, float z, float yaw) { + Mat4 m = Mat4::identity(); + float c = std::cos(yaw), s = std::sin(yaw); + m.at(0, 0) = c; m.at(0, 1) = -s; + m.at(1, 0) = s; m.at(1, 1) = c; + m.at(0, 3) = x; m.at(1, 3) = y; m.at(2, 3) = z; + return m; +} + +// ============================================================== Vulkan 자원 +struct Buffer { + VkBuffer buf = VK_NULL_HANDLE; + VkDeviceMemory mem = VK_NULL_HANDLE; + void* mapped = nullptr; + VkDeviceSize size = 0; +}; + +struct Image { + VkImage img = VK_NULL_HANDLE; + VkDeviceMemory mem = VK_NULL_HANDLE; + VkImageView view = VK_NULL_HANDLE; +}; + +// 환경변수 켜짐: 있고 비어 있지 않고 "0" 이 아니면 (VKGS_BITONIC=0 이 켜는 것으로 읽히지 않게) +inline bool env_flag(const char* name) { + const char* v = std::getenv(name); + return v && *v && std::string(v) != "0"; +} + +inline const char* vk_format_name(VkFormat f) { + switch (f) { + case VK_FORMAT_R32G32B32A32_SFLOAT: return "rgba32f"; + case VK_FORMAT_R16G16B16A16_SFLOAT: return "rgba16f"; + case VK_FORMAT_R32G32_SFLOAT: return "rg32f"; + case VK_FORMAT_R16G16_SFLOAT: return "rg16f"; + case VK_FORMAT_R32_SFLOAT: return "r32f"; + case VK_FORMAT_R16_SFLOAT: return "r16f"; + default: return "?"; + } +} + +// 렌더 그래프가 만든 버퍼 (JSON "create") +struct GraphBuffer { + Buffer buf; + uint32_t slot = 0; // bindless SSBO 슬롯 +}; +// 렌더 그래프가 만든 이미지 (JSON "create") 와 렌더 패스 (JSON "targets") +struct GraphImage { + Image img; + VkFormat fmt = VK_FORMAT_UNDEFINED; + uint32_t slot = 0; // bindless storage image 슬롯 (binding 4 = 4채널, 5 = 1채널) +}; +struct GraphTarget { + VkRenderPass rp = VK_NULL_HANDLE; + VkFramebuffer fb = VK_NULL_HANDLE; + uint32_t n_color = 0; +}; +// 셰이더 그래프 매개변수 (scripts/shader_graph.py 가 만든 <이름>.params.json) +struct ShaderGraphParam { + std::string name; // <노드>.<매개변수> + uint32_t index = 0; + float def = 0, min = 0, max = 1; +}; +struct ShaderGraphInfo { + std::string name, resource; + std::vector params; +}; + +// 합성 날씨·조도 (shaders/weather.comp). 0 이면 그 효과 없음. enabled = false 면 렌더 그대로. +struct WeatherParams { + bool enabled = false; + float daylight = 1.0f; // 1 한낮 · 0.4 해질녘 · 0 밤 + float cloud = 0.0f; // 구름 (흐림) + float rain = 0.0f; // 비 (빗줄기 + 젖은 노면 + 물안개) + float fog = 0.0f; // 안개 + float wet = 0.0f; // 비 없이 젖은 노면만 + float snow = 0.0f; // 눈 (눈송이 + 쌓인 눈 + 눈안개) + float snow_cover = 0.0f; // 내리지 않고 쌓인 눈만 + int headlights = -1; // -1 자동(어두우면 켬), 0 끔, 1 켬 +}; + +struct WeatherUBO { // std140 — weather.comp 와 같은 순서 + float p0[4], p1[4], campos[4], c2w[16], intr[4], size[4], hl_pos[4], hl_dir[4], p2[4], p3[4]; + float p4[4], p5[4], p6[4], p7[4]; // 자차 속도·노출, 바람·렌즈·웅덩이, 조리개·안개 높이·입자 상자, 볼류메트릭 +}; + +// ---------------------------------------------------------------- 시뮬레이터 설정 (configs/sim.json) +// 하드코딩을 줄이려고 조정값을 한 파일로 모았다. 기본값은 파일이 없거나 항목이 빠졌을 때만 쓰인다. +struct WeatherPreset { + std::string name, label; + std::vector> values; +}; +struct SimConfig { + WeatherParams weather_defaults; + std::string weather_start = "clear"; // 창 모드에서 --weather 가 없을 때 시작 프리셋 (빈 문자열 = 날씨 끔) + std::vector presets; + float hl_intensity = 0.9f, hl_cone_deg = 28.0f, hl_auto_below = 0.3f, hl_height = 0.7f, hl_pitch = -0.05f, + hl_inset = 0.25f; + // 날씨 효과 조정값 (sim.json weather.fx) — 강수 입자·렌즈·물웅덩이·안개 + struct WeatherFx { + int rain_particles = 80000, snow_particles = 120000; // configs/sim.json 과 같게 + float box_half_xy = 15.0f, box_half_z = 6.0f, exposure_s = 0.02f, aperture_m = 0.0025f; + float wind[2] = {1.0f, 0.3f}; + float lens_drops = 1.0f, puddles = 1.0f, fog_height_m = 20.0f; + int volumetric_steps = 12; + } fx; + std::string sky_mode = "sim"; // sim | learned | ibl + float sun_azimuth_deg = 120.0f, cloud_speed[2] = {0.012f, 0.004f}; + float chase_back = 6.5f, chase_back_per_mps = 0.15f, chase_height = 1.9f, chase_look_ahead = 8.0f, + chase_look_height = 1.1f, chase_yaw_offset_deg = -10.0f, chase_yaw_tau = 0.4f, chase_dist_tau = 0.8f; + // 휠 · 가운데 버튼 드래그로 차 뒤 카메라 거리 배율 (1 = back_m). 높이는 배율^zoom_height_exp 로 같이 — 멀어지면 내려다본다 + float chase_zoom_min = 0.4f, chase_zoom_max = 5.0f, chase_zoom_step = 1.15f, chase_zoom_height_exp = 0.5f; + float max_speed_kmh = 30.0f; + float autopilot_kmh = 20.0f; // 자동 주행 목표 속도 (sim.json drive.autopilot_kmh, 뷰어 T) + bool curb = true; + float obst_tol = 0.1f, curb_tol = 0.15f; + int graze_samples = 2; // 이 이하로 닿은 샘플은 스침으로 보고 막지 않는다 (drive.hpp ContactInfo::depth) + std::vector lidars; // 레이캐스트 센서 (sensors.hpp, 설정 sensors.lidar / sensors.radar) + std::vector radars; + bool front_cams = false; + bool bev_pip = false; // BEV 미리보기로 시작 (설정 ui.bev_pip, N) + float bev_pip_height = 40.0f; // BEV 미리보기 카메라 높이 m (ui.bev_pip_height_m) + float pip_strip_height_frac = 0.22f; + // 카메라 모드 (sim.json camera_modes): 깊이 색 눈금의 최대 거리, 시맨틱 색 (configs/semantic.json 의 sky · vehicle) + float depth_max_m = 80.0f; + uint32_t sem_sky = 0xFFB48246u, sem_vehicle = 0xFF8E0000u; // RGBA8 (R 이 낮은 바이트) // 전방 3대 · BEV 미리보기 줄의 최대 높이 (화면 높이 비율, ui.pip_strip_height_frac) + bool graph_window = false; // 렌더 그래프 창 (F2) + std::string tracy_profiler = VKGS_TRACY_PROFILER_DEFAULT; + uint32_t obj_max_gaussians = 1500000, obj_max_instances = 4096; // 장면 객체 용량 (설정 objects) // Tracy GUI 실행 파일 (scripts/build_tracy.sh). 설정 tools.tracy_profiler + int png_compression = 1; // stb PNG zlib 수준 (8 = stb 기본, 느림). Tracy 실측: 기본 수준이 오프스크린 시간의 94% + int png_writers = 8; // PNG 인코딩 백그라운드 스레드 수 (렌더가 기다리지 않게) + int png_filter = 1; // PNG 행 필터 고정 (1 = Sub). -1 이면 stb 가 행마다 5종을 다 시험해 5배 느리다 + nlohmann::json shader_graphs = nlohmann::json::object(); // 셰이더 그래프별 {enabled, params{<노드>.<매개변수>: 값}} + nlohmann::json passes = nlohmann::json::object(); // 렌더 그래프 패스별 {enabled} 덮어쓰기 (fxaa 등) + std::string render_graph = VKGS_RENDER_GRAPH_DEFAULT; // 렌더 그래프 JSON (configs/render_graph.json) +}; +SimConfig g_cfg; + +// 날씨 항목 하나 (이름 = configs/sim.json·--weather 의 키) +void set_weather_field(WeatherParams& w, const std::string& k, float v) { + if (k == "daylight") w.daylight = v; + else if (k == "cloud") w.cloud = v; + else if (k == "rain") w.rain = v; + else if (k == "fog") w.fog = v; + else if (k == "wet") w.wet = v; + else if (k == "snow") w.snow = v; + else if (k == "snow_cover") w.snow_cover = v; + else if (k == "headlights") w.headlights = int(v); + else throw std::runtime_error("알 수 없는 날씨 항목: " + k); +} + +void load_sim_config(const std::string& path) { + std::ifstream f(path); + if (!f) throw std::runtime_error("설정 파일을 열 수 없음: " + path); + nlohmann::json j = nlohmann::json::parse(f, nullptr, true, true); + SimConfig& c = g_cfg; + if (j.contains("weather")) { + const auto& w = j["weather"]; + if (w.contains("defaults")) + for (auto& [k, v] : w["defaults"].items()) set_weather_field(c.weather_defaults, k, v.get()); + c.weather_start = w.value("start", c.weather_start); + if (w.contains("presets")) { + c.presets.clear(); + for (const auto& p : w["presets"]) { + WeatherPreset wp{p.value("name", ""), p.value("label", p.value("name", "")), {}}; + if (p.contains("values")) + for (auto& [k, v] : p["values"].items()) wp.values.push_back({k, v.get()}); + c.presets.push_back(wp); + } + } + if (w.contains("fx")) { + const auto& f = w["fx"]; + auto& x = c.fx; + x.rain_particles = f.value("rain_particles", x.rain_particles); + x.snow_particles = f.value("snow_particles", x.snow_particles); + x.box_half_xy = f.value("box_half_xy_m", x.box_half_xy); + x.box_half_z = f.value("box_half_z_m", x.box_half_z); + x.exposure_s = f.value("exposure_s", x.exposure_s); + x.aperture_m = f.value("aperture_m", x.aperture_m); + if (f.contains("wind_mps")) { x.wind[0] = f["wind_mps"][0]; x.wind[1] = f["wind_mps"][1]; } + x.lens_drops = f.value("lens_drops", x.lens_drops); + x.puddles = f.value("puddles", x.puddles); + x.fog_height_m = f.value("fog_height_m", x.fog_height_m); + x.volumetric_steps = f.value("volumetric_steps", x.volumetric_steps); + } + if (w.contains("headlights")) { + const auto& h = w["headlights"]; + c.hl_intensity = h.value("intensity", c.hl_intensity); + c.hl_cone_deg = h.value("cone_deg", c.hl_cone_deg); + c.hl_auto_below = h.value("auto_below_daylight", c.hl_auto_below); + c.hl_height = h.value("height_m", c.hl_height); + c.hl_pitch = h.value("pitch", c.hl_pitch); + c.hl_inset = h.value("inset_m", c.hl_inset); + } + } + if (j.contains("sky")) { + const auto& k = j["sky"]; + c.sky_mode = k.value("mode", c.sky_mode); + c.sun_azimuth_deg = k.value("sun_azimuth_deg", c.sun_azimuth_deg); + if (k.contains("cloud_speed")) { + c.cloud_speed[0] = k["cloud_speed"][0].get(); + c.cloud_speed[1] = k["cloud_speed"][1].get(); + } + } + if (j.contains("chase_camera")) { + const auto& k = j["chase_camera"]; + c.chase_back = k.value("back_m", c.chase_back); + c.chase_back_per_mps = k.value("back_per_mps", c.chase_back_per_mps); + c.chase_height = k.value("height_m", c.chase_height); + c.chase_look_ahead = k.value("look_ahead_m", c.chase_look_ahead); + c.chase_look_height = k.value("look_height_m", c.chase_look_height); + c.chase_yaw_offset_deg = k.value("yaw_offset_deg", c.chase_yaw_offset_deg); + c.chase_yaw_tau = k.value("yaw_tau_s", c.chase_yaw_tau); + c.chase_dist_tau = k.value("dist_tau_s", c.chase_dist_tau); + c.chase_zoom_min = k.value("zoom_min", c.chase_zoom_min); + c.chase_zoom_max = k.value("zoom_max", c.chase_zoom_max); + c.chase_zoom_step = k.value("zoom_step", c.chase_zoom_step); + c.chase_zoom_height_exp = k.value("zoom_height_exp", c.chase_zoom_height_exp); + } + if (j.contains("drive")) { + c.max_speed_kmh = j["drive"].value("max_speed_kmh", c.max_speed_kmh); + c.autopilot_kmh = j["drive"].value("autopilot_kmh", c.autopilot_kmh); + } + if (j.contains("shader_graphs")) c.shader_graphs = j["shader_graphs"]; + if (j.contains("camera_modes")) { + const auto& m = j["camera_modes"]; + c.depth_max_m = m.value("depth_max_m", c.depth_max_m); + if (m.contains("semantic")) { // sim.json 기준 상대 경로 + std::filesystem::path sp = std::filesystem::path(path).parent_path() / m["semantic"].get(); + std::ifstream sf(sp); + if (sf) { + nlohmann::json sj = nlohmann::json::parse(sf, nullptr, true, true); + auto pack = [](const nlohmann::json& rgb) { + return uint32_t(rgb[0].get()) | uint32_t(rgb[1].get()) << 8 | uint32_t(rgb[2].get()) << 16 | 0xFF000000u; + }; + for (const auto& cl : sj["classes"]) { + if (cl["name"] == "sky") c.sem_sky = pack(cl["rgb"]); + if (cl["name"] == "vehicle") c.sem_vehicle = pack(cl["rgb"]); + } + } + } + } + if (j.contains("passes")) c.passes = j["passes"]; + if (j.contains("objects")) { + c.obj_max_gaussians = j["objects"].value("max_gaussians", c.obj_max_gaussians); + c.obj_max_instances = j["objects"].value("max_instances", c.obj_max_instances); + } + if (j.contains("tools")) { + std::string t = j["tools"].value("tracy_profiler", std::string()); + if (!t.empty()) c.tracy_profiler = t; + } + if (j.contains("output")) { + c.png_compression = j["output"].value("png_compression", c.png_compression); + c.png_writers = j["output"].value("png_writers", c.png_writers); + c.png_filter = j["output"].value("png_filter", c.png_filter); + } + if (j.contains("ui")) { + c.front_cams = j["ui"].value("front_cams", c.front_cams); + c.bev_pip = j["ui"].value("bev_pip", c.bev_pip); + c.bev_pip_height = j["ui"].value("bev_pip_height_m", c.bev_pip_height); + c.pip_strip_height_frac = j["ui"].value("pip_strip_height_frac", c.pip_strip_height_frac); + c.graph_window = j["ui"].value("render_graph", c.graph_window); + } + if (j.contains("render_graph")) { + std::filesystem::path rg = j["render_graph"].get(); + c.render_graph = rg.is_absolute() ? rg.string() : (std::filesystem::path(path).parent_path() / rg).string(); + } + if (j.contains("sensors")) { + const auto& sj = j["sensors"]; + auto mount = [](const nlohmann::json& x, vkgs::SensorMount& m) { + m.name = x.value("name", m.name); + for (int a = 0; a < 3; ++a) { + if (x.contains("pos")) m.pos[a] = x["pos"][a].get(); + if (x.contains("rpy_deg")) m.rpy[a] = x["rpy_deg"][a].get(); + } + }; + c.lidars.clear(); + c.radars.clear(); + for (const auto& x : sj.value("lidar", nlohmann::json::array())) { + vkgs::LidarConfig l; + mount(x, l.mount); + l.channels = x.value("channels", l.channels); + if (x.contains("vfov_deg")) l.vfov_lo = x["vfov_deg"][0].get(), l.vfov_hi = x["vfov_deg"][1].get(); + l.h_res = x.value("h_res_deg", l.h_res); + l.range = x.value("range_m", l.range); + l.noise = x.value("noise_m", l.noise); + l.hz = x.value("hz", l.hz); + c.lidars.push_back(l); + } + for (const auto& x : sj.value("radar", nlohmann::json::array())) { + vkgs::RadarConfig r; + mount(x, r.mount); + r.hfov = x.value("hfov_deg", r.hfov); + r.vfov = x.value("vfov_deg", r.vfov); + r.range = x.value("range_m", r.range); + r.az_res = x.value("az_res_deg", r.az_res); + r.el_beams = x.value("el_beams", r.el_beams); + r.max_points = x.value("max_points", r.max_points); + r.min_height = x.value("min_height_m", r.min_height); + r.noise_range = x.value("noise_range_m", r.noise_range); + r.noise_vel = x.value("noise_vel_mps", r.noise_vel); + r.hz = x.value("hz", r.hz); + c.radars.push_back(r); + } + } + if (j.contains("collision")) { + const auto& k = j["collision"]; + c.curb = k.value("curb", c.curb); + c.obst_tol = k.value("obstacle_tol_m", c.obst_tol); + c.curb_tol = k.value("curb_tol_m", c.curb_tol); + c.graze_samples = k.value("graze_samples", c.graze_samples); + } + std::cout << "설정 " << path << " (날씨 프리셋 " << c.presets.size() << "개, 하늘 " << c.sky_mode << ")\n"; +} + +struct CameraUBO { // shaders/gs_common.glsl 의 Camera (std140) + float w2c[16]; + float intr[4]; + float size[4]; + float campos[4]; + uint32_t counts[4]; + float obj_xform[16]; + uint32_t obj[4]; + uint32_t objs[4]; // 장면 객체: 가우시안·객체 번호·객체 표 SSBO 슬롯, 객체 가우시안 수 (gs_common.glsl) + uint32_t mode[4]; // 카메라 모드, 시맨틱 차량 색, 시맨틱 하늘 색, 깊이 최대 거리 (float 비트) — camera_mode.comp +}; + +struct Timing { + double preprocess_ms = 0, sort_ms = 0, raster_ms = 0; + uint32_t visible = 0; +}; + +struct Tex { // 샘플링용 텍스처 (KTX2 에서) + VkImage img = VK_NULL_HANDLE; + VkDeviceMemory mem = VK_NULL_HANDLE; + VkImageView view = VK_NULL_HANDLE; + uint32_t levels = 1; +}; + +struct Sphere { // IBL 검증용 PBR 구 + Vec3 center; + float radius = 0.5f; + float albedo[3] = {0.8f, 0.8f, 0.8f}; + float roughness = 0.5f, metallic = 0.0f; +}; + +class Renderer { +public: + Renderer(bool want_window, int width, int height) + : width_(width), height_(height), want_window_(want_window) { + create_instance(); + if (want_window_) create_window(); + pick_device(); + create_device(); + create_targets(); + create_pipelines(); + VkCommandPoolCreateInfo pi{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO}; + pi.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + pi.queueFamilyIndex = qfam_; + VK(vkCreateCommandPool(dev_, &pi, nullptr, &pool_)); + VkCommandBufferAllocateInfo ai{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO}; + ai.commandPool = pool_; + ai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + ai.commandBufferCount = 1; + VK(vkAllocateCommandBuffers(dev_, &ai, &cmd_)); +#ifdef TRACY_ENABLE + { // GPU 구간 (Vulkan 타임스탬프 → Tracy GPU 타임라인). 보정용 명령 버퍼를 따로 쓴다 + VkCommandBuffer tc; + VK(vkAllocateCommandBuffers(dev_, &ai, &tc)); + tracy_vk_ = TracyVkContext(phys_, dev_, queue_, tc); + TracyVkContextName(tracy_vk_, "vkgs", 4); + } +#endif + VkFenceCreateInfo fi{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO}; + VK(vkCreateFence(dev_, &fi, nullptr, &fence_)); + VkQueryPoolCreateInfo qi{VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO}; + qi.queryType = VK_QUERY_TYPE_TIMESTAMP; + qi.queryCount = 4; + VK(vkCreateQueryPool(dev_, &qi, nullptr, &queries_)); + if (want_window_) create_swapchain(); + } + + ~Renderer() { + try { flush_png(); } catch (const std::exception& e) { std::cerr << e.what() << "\n"; } + if (!dev_) return; + vkDeviceWaitIdle(dev_); +#ifdef TRACY_ENABLE + if (tracy_vk_) TracyVkDestroy(tracy_vk_); // 쿼리 풀을 장치보다 먼저 +#endif + if (ui_) { + ImGui_ImplVulkan_Shutdown(); + ImGui_ImplGlfw_Shutdown(); + ImGui::DestroyContext(); + } + // 프로세스 종료 직전이라 개별 해제는 드라이버에 맡기고 핵심만 정리한다. + destroy_swapchain(); + vkDestroyDevice(dev_, nullptr); + if (surface_) vkDestroySurfaceKHR(inst_, surface_, nullptr); + vkDestroyInstance(inst_, nullptr); + if (window_) glfwDestroyWindow(window_); + if (want_window_) glfwTerminate(); + } + + GLFWwindow* window() const { return window_; } + std::string device_name() const { return props_.deviceName; } + + // asset 이 있으면 씬 가우시안 뒤에 붙인다. 정렬을 한 번에 하므로 장면과 서로 올바르게 가린다. + void upload(const vkgs::Scene& scene, const vkgs::Scene* asset = nullptr) { + obj_start_ = static_cast(scene.gaussians.size()); + if (asset) asset_gauss_ = asset->gaussians; + if (asset && !asset->gaussians.empty()) { + for (int a = 0; a < 3; ++a) { + obj_min_[a] = asset->bbox_min[a]; + obj_max_[a] = asset->bbox_max[a]; + } + vkgs::Scene merged = scene; + merged.gaussians.insert(merged.gaussians.end(), asset->gaussians.begin(), asset->gaussians.end()); + return upload_impl(merged); + } + upload_impl(scene); + } + bool has_obj() const { return obj_start_ < n_; } + + // ---- 구간 모델 이어 달리기 (segment_map.hpp) ---- + // 버퍼를 이 가우시안 수만큼 잡는다 (upload 전에). 구간을 바꿀 때 다시 잡지 않고 내용만 바꾼다. + void set_scene_capacity(uint32_t n) { scene_cap_ = n; } + // 배경 가우시안을 다른 구간 것으로 바꾼다 (자차 3DGS 에셋은 뒤에 다시 붙인다). 그리는 중인 프레임을 기다린다. + void swap_scene(const vkgs::Scene& scene) { + ZoneScopedN("swap_scene"); + uint64_t total = scene.gaussians.size() + asset_gauss_.size(); + if (total > gauss_cap_) + throw std::runtime_error("구간 가우시안 " + std::to_string(total) + "개 > 버퍼 용량 " + std::to_string(gauss_cap_)); + VK(vkDeviceWaitIdle(dev_)); + VkDeviceSize gb = VkDeviceSize(scene.gaussians.size()) * sizeof(vkgs::GpuGaussian); + upload_to(gauss_, scene.gaussians.data(), gb); + if (!asset_gauss_.empty()) + upload_to(gauss_, asset_gauss_.data(), asset_gauss_.size() * sizeof(vkgs::GpuGaussian), gb); + obj_start_ = uint32_t(scene.gaussians.size()); + n_ = uint32_t(total); + } + + // ---- 장면 객체 (차량 에셋 등, scene_objects.hpp) ---- + // 용량은 upload 전에 정한다 (정렬 키·스플랫 버퍼가 배경 + 객체 용량만큼 잡힌다) + void set_object_capacity(uint32_t max_gaussians, uint32_t max_instances) { + obj_cap_ = max_gaussians; + obj_inst_cap_ = max_instances; + } + // 객체 가우시안(박스 로컬)과 가우시안마다 객체 번호. 추가·삭제할 때마다 부른다 — 배경은 다시 올리지 않는다 + void set_scene_objects(const std::vector& g, const std::vector& ids) { + if (g.size() > obj_cap_) + throw std::runtime_error("장면 객체 가우시안 " + std::to_string(g.size()) + "개 > 용량 " + std::to_string(obj_cap_) + + " (sim.json objects.max_gaussians)"); + if (!obj_gauss_.buf) { // 처음 한 번 용량만큼 잡는다 + obj_gauss_ = make_buffer(VkDeviceSize(std::max(obj_cap_, 1u)) * sizeof(vkgs::GpuGaussian), + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + obj_ids_ = make_buffer(VkDeviceSize(std::max(obj_cap_, 1u)) * 4, + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + obj_table_ = make_buffer(VkDeviceSize(std::max(obj_inst_cap_, 1u)) * sizeof(vkgs::ObjInstanceGpu), + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + std::memset(obj_table_.mapped, 0, obj_table_.size); + obj_gauss_slot_ = bl_new_ssbo(); + obj_ids_slot_ = bl_new_ssbo(); + obj_table_slot_ = bl_new_ssbo(); + bl_buffer(0, obj_gauss_slot_, obj_gauss_.buf, 0, VK_WHOLE_SIZE); + bl_buffer(0, obj_ids_slot_, obj_ids_.buf, 0, VK_WHOLE_SIZE); + bl_buffer(0, obj_table_slot_, obj_table_.buf, 0, VK_WHOLE_SIZE); + } + if (!g.empty()) { + upload_to(obj_gauss_, g.data(), g.size() * sizeof(vkgs::GpuGaussian)); + upload_to(obj_ids_, ids.data(), ids.size() * 4); + } + n_obj_ = uint32_t(g.size()); + } + // 객체 표 (프레임마다): 변환·보이기 + void set_object_table(const std::vector& t) { + if (!obj_table_.mapped) return; + if (t.size() > obj_inst_cap_) throw std::runtime_error("장면 객체 수가 용량(objects.max_instances)을 넘음"); + std::memcpy(obj_table_.mapped, t.data(), t.size() * sizeof(vkgs::ObjInstanceGpu)); + } + uint32_t n_scene_objects_gaussians() const { return n_obj_; } + + // 가림막 = 에셋 경계 상자를 10% 줄인 것 (가장자리의 흐린 가우시안까지 가리지 않게) + void write_occluder(const Mat4& xf) { + if (!occluder_.buf) + { + occluder_ = make_buffer(36 * sizeof(LineVertex), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + occluder_slot_ = bl_new_ssbo(); + bl_buffer(0, occluder_slot_, occluder_.buf, 0, VK_WHOLE_SIZE); + } + float lo[3], hi[3]; + for (int a = 0; a < 3; ++a) { + float c = 0.5f * (obj_min_[a] + obj_max_[a]), h = 0.45f * (obj_max_[a] - obj_min_[a]); + lo[a] = c - h; + hi[a] = c + h; + } + auto P = [&](int k) { + float x = (k & 1) ? hi[0] : lo[0], y = (k & 2) ? hi[1] : lo[1], z = (k & 4) ? hi[2] : lo[2]; + LineVertex v{}; + for (int r = 0; r < 3; ++r) v.pos[r] = xf.at(r, 0) * x + xf.at(r, 1) * y + xf.at(r, 2) * z + xf.at(r, 3); + return v; + }; + const int quads[6][4] = {{0, 1, 3, 2}, {4, 5, 7, 6}, {0, 1, 5, 4}, {2, 3, 7, 6}, {0, 2, 6, 4}, {1, 3, 7, 5}}; + LineVertex* out = static_cast(occluder_.mapped); + int n = 0; + for (auto& q : quads) + for (int t : {0, 1, 2, 0, 2, 3}) out[n++] = P(q[t]); + } + + void upload_impl(const vkgs::Scene& scene) { + n_ = static_cast(scene.gaussians.size()); + sh_degree_ = static_cast(scene.sh_degree); + // 구간을 바꿔 그릴 때(swap_scene)는 가장 큰 구간 + 자차 에셋만큼 미리 잡는다 + gauss_cap_ = std::max(n_, uint64_t(scene_cap_) + asset_gauss_.size()); + npow2_ = 1; + while (npow2_ < gauss_cap_ + obj_cap_) npow2_ <<= 1; // 장면 객체 가우시안까지 (정렬 키·스플랫 자리) + VkDeviceSize gsize = VkDeviceSize(n_) * sizeof(vkgs::GpuGaussian); + gauss_ = make_buffer(VkDeviceSize(gauss_cap_) * sizeof(vkgs::GpuGaussian), + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + upload_to(gauss_, scene.gaussians.data(), gsize); + splats_ = make_buffer(VkDeviceSize(gauss_cap_ + obj_cap_) * 48, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + keys_ = make_buffer(VkDeviceSize(npow2_) * 8, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + draw_ = make_buffer(16, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT | + VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + ubo_ = make_buffer(sizeof(CameraUBO), VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + draw_readback_ = make_buffer(16, VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + write_descriptors(); + } + + // 정적 선(맵) + 동적 선(자차 박스 등, 매 프레임 갱신) + void set_static_lines(const std::vector& v) { + n_static_lines_ = static_cast(v.size()); + if (v.empty()) return; + static_lines_ = make_buffer(v.size() * sizeof(LineVertex), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (!static_lines_slot_) static_lines_slot_ = bl_new_ssbo(); + bl_buffer(0, static_lines_slot_, static_lines_.buf, 0, VK_WHOLE_SIZE); + std::memcpy(static_lines_.mapped, v.data(), v.size() * sizeof(LineVertex)); + } + void set_dynamic_lines(const std::vector& v) { + const size_t cap = 262144; // 자차 박스 + 주행 흔적 + 라이다 점 (점 하나 = 짧은 세로선 2정점, 32채널 약 3.5만 점) + if (!dyn_lines_.buf) { + dyn_lines_ = make_buffer(cap * sizeof(LineVertex), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + dyn_lines_slot_ = bl_new_ssbo(); + bl_buffer(0, dyn_lines_slot_, dyn_lines_.buf, 0, VK_WHOLE_SIZE); + } + n_dyn_lines_ = static_cast(std::min(v.size(), cap)); + std::memcpy(dyn_lines_.mapped, v.data(), n_dyn_lines_ * sizeof(LineVertex)); + } + bool show_lines = true; + bool show_env = true; + float env_lod = 0.0f; // 배경에 쓸 specular mip (0 = 원본) + + // nusim render ibl 이 만든 디렉터리: env_specular.ktx2, env_irradiance.ktx2, brdf_lut.ktx2 + void load_ibl(const std::string& dir) { + if (!bc_ok_) throw std::runtime_error("이 GPU 는 BC 텍스처 압축(BC6H)을 지원하지 않음"); + spec_tex_ = load_texture(dir + "/env_specular.ktx2"); + irr_tex_ = load_texture(dir + "/env_irradiance.ktx2"); + lut_tex_ = load_texture(dir + "/brdf_lut.ktx2"); + VkSamplerCreateInfo si{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO}; + si.magFilter = si.minFilter = VK_FILTER_LINEAR; + si.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR; + si.addressModeU = si.addressModeV = si.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + si.maxLod = 16.0f; + VK(vkCreateSampler(dev_, &si, nullptr, &ibl_sampler_)); + bl_image(3, kCubeSpec, ibl_sampler_, spec_tex_.view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + bl_image(3, kCubeIrr, ibl_sampler_, irr_tex_.view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + bl_image(2, kTexLut, ibl_sampler_, lut_tex_.view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + ibl_loaded_ = true; + std::cout << "IBL " << dir << " (specular " << spec_tex_.levels << " mips, BC6H)\n"; + } + + // glTF 모델 추가 → 모델 번호. 여러 개 (다른 차량용, 자차용 …). IBL 이 먼저 로드돼 있어야 조명이 있다. + int add_gltf(const vkgs::GltfModel& m) { + if (!ibl_loaded_) throw std::runtime_error("glTF 차량에는 --ibl 이 필요함 (PBR 조명)"); + if (!gltf_pipes_) create_gltf_pipelines(); + GltfGpu g; + // 정점·인덱스 + g.vb = make_buffer(m.vertices.size() * sizeof(vkgs::GltfVertex), + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + upload_to(g.vb, m.vertices.data(), g.vb.size); + g.vb_slot = bl_new_ssbo(); // 정점은 셰이더가 bindless SSBO 에서 직접 읽는다 (pbr_gltf.vert) + bl_buffer(0, g.vb_slot, g.vb.buf, 0, VK_WHOLE_SIZE); + g.ib = make_buffer(m.indices.size() * 4, VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + upload_to(g.ib, m.indices.data(), g.ib.size); + g.prims = m.prims; + for (const auto& mt : m.materials) g.translucent.push_back(mt.translucent()); + + // 텍스처 (mip 생성) + for (const auto& t : m.textures) g.tex.push_back(make_texture_rgba8(t.width, t.height, t.rgba.data(), t.srgb)); + // 텍스처 → bindless 2D 슬롯 + std::vector tex_slot(g.tex.size()); + for (size_t t = 0; t < g.tex.size(); ++t) { + tex_slot[t] = int32_t(bl_new_tex()); + bl_image(2, uint32_t(tex_slot[t]), gltf_sampler_, g.tex[t].view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + } + // 재질 SSBO (std430, 304 B) — 모델 하나에 하나. 셰이더는 (SSBO 슬롯, 재질 번호) 를 push 로 받는다. + struct MatGPU { + float base[4], emissive[4], mrno[4], alpha[4], cc[4]; + int32_t tc0[4], tc1[4]; + float xf[10][4]; + int32_t tex0[4], tex1[4]; + }; + static_assert(sizeof(MatGPU) == 304, "std430 레이아웃 (pbr_gltf.frag Material)"); + size_t nm = std::max(1, m.materials.size()); + g.ubo = make_buffer(sizeof(MatGPU) * nm, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + for (size_t i = 0; i < nm; ++i) { + vkgs::GltfMaterial mt = i < m.materials.size() ? m.materials[i] : vkgs::GltfMaterial{}; + MatGPU u{}; + std::memcpy(u.base, mt.base, 16); + for (int k = 0; k < 3; ++k) u.emissive[k] = mt.emissive[k] * mt.emissive_strength; + u.mrno[0] = mt.metallic; u.mrno[1] = mt.roughness; u.mrno[2] = mt.normal_scale; u.mrno[3] = mt.occlusion_strength; + u.alpha[0] = mt.alpha_cutoff; u.alpha[1] = float(mt.alpha_mode); u.alpha[2] = mt.transmission; + u.alpha[3] = mt.tex[vkgs::kNormal] >= 0 ? 1.0f : 0.0f; + u.cc[0] = mt.clearcoat; u.cc[1] = mt.clearcoat_roughness; + for (int k = 0; k < 4; ++k) u.tc0[k] = mt.texcoord[k]; + u.tc1[0] = mt.texcoord[vkgs::kEmissive]; + for (int k = 0; k < vkgs::kSlots; ++k) { + if (mt.uv_xform[k][0] == 0 && mt.uv_xform[k][4] == 0) { // 기본값(0)이면 항등 + float id[6] = {1, 0, 0, 0, 1, 0}; + std::memcpy(mt.uv_xform[k], id, sizeof(id)); + } + u.xf[2 * k][0] = mt.uv_xform[k][0]; u.xf[2 * k][1] = mt.uv_xform[k][1]; u.xf[2 * k][2] = mt.uv_xform[k][2]; + u.xf[2 * k + 1][0] = mt.uv_xform[k][3]; u.xf[2 * k + 1][1] = mt.uv_xform[k][4]; u.xf[2 * k + 1][2] = mt.uv_xform[k][5]; + } + auto slot = [&](int t) { return t >= 0 ? tex_slot[t] : int32_t(kTexWhite); }; + for (int k = 0; k < 4; ++k) u.tex0[k] = slot(mt.tex[k]); + u.tex1[0] = slot(mt.tex[vkgs::kEmissive]); + std::memcpy(static_cast(g.ubo.mapped) + sizeof(MatGPU) * i, &u, sizeof(u)); + } + g.mat_slot = bl_new_ssbo(); + bl_buffer(0, g.mat_slot, g.ubo.buf, 0, VK_WHOLE_SIZE); + gltf_models_.push_back(std::move(g)); + std::cout << "glTF #" << gltf_models_.size() - 1 << " 정점 " << m.vertices.size() << ", 프리미티브 " << m.prims.size() << ", 재질 " + << m.materials.size() << ", 텍스처 " << m.textures.size() << "\n"; + return int(gltf_models_.size()) - 1; + } + + void create_gltf_pipelines() { + // 파이프라인(gltf_opaque, gltf_blend, shadow)은 렌더 그래프 JSON 레지스트리에서 이미 만들었다 + + const uint8_t white[4] = {255, 255, 255, 255}; + white_tex_ = make_texture_rgba8(1, 1, white, false); + VkSamplerCreateInfo si{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO}; + si.magFilter = si.minFilter = VK_FILTER_LINEAR; + si.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR; + si.addressModeU = si.addressModeV = si.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT; + si.maxLod = 16.0f; + si.anisotropyEnable = aniso_ok_; + si.maxAnisotropy = aniso_ok_ ? std::min(8.0f, props_.limits.maxSamplerAnisotropy) : 1.0f; + VK(vkCreateSampler(dev_, &si, nullptr, &gltf_sampler_)); + bl_image(2, kTexWhite, gltf_sampler_, white_tex_.view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + gltf_pipes_ = true; + } + // 이번 프레임에 그릴 glTF 인스턴스들 (모델 번호, 모델 → 씬 변환) + void set_vehicles(const std::vector>& draws) { vehicles_ = draws; } + // 자차 glTF 의 부품별 로컬 변환 — [0] 차체 (서스펜션 기울기), [1 + 2k] 바퀴 k (구름·조향), + // [2 + 2k] 바퀴 k 의 캘리퍼 (조향만). 비면 강체로 그린다. + void set_ego_parts(const std::vector& parts) { ego_parts_ = parts; } + // 접지 그림자: (모델 번호, 박스 바닥 중앙 → 씬, x/y 에 길이·폭 곱함). 모델 번호로 자차 숨김을 따른다 + void set_shadows(const std::vector>& s) { shadows_ = s; } + float shadow_opacity = 0.55f; + + // 학습된 하늘 큐브맵 (nusim recon train --sky 의 sky.ktx2). 학습 때 합성식 (스플랫 + (1−α)·하늘) 과 + // 같아지도록 스플랫보다 먼저 배경으로 그린다. 배경 셰이더는 push 로 받은 큐브 번호(kCubeSky)를 읽는다. + void load_sky(const std::string& path) { + sky_tex_ = load_texture(path); + VkSamplerCreateInfo si{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO}; + si.magFilter = si.minFilter = VK_FILTER_LINEAR; + si.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR; + si.addressModeU = si.addressModeV = si.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + VK(vkCreateSampler(dev_, &si, nullptr, &sky_sampler_)); + bl_image(3, kCubeSky, sky_sampler_, sky_tex_.view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + sky_loaded_ = true; + std::cout << "하늘 " << path << " (학습된 큐브맵 " << sky_tex_.levels << " mip, 배경)\n"; + } + float exposure = 1.0f; + int ego_model = -1; + WeatherParams weather; + // 배경 하늘: Sim = 시뮬레이션 하늘 (shaders/sky_sim.frag, 기본), Learned = 학습된 하늘 큐브맵 (학습기 일치 검사), + // Ibl = 환경맵. 3DGS 하늘은 학습 때 하늘 분리(--sky)로 비우므로 그 자리에 이 배경이 보인다. + enum class SkyMode { Sim, Learned, Ibl }; + SkyMode sky_mode_ = SkyMode::Sim; + float weather_time = 0; // 빗줄기 애니메이션 시계 (s) + float ground_z = 0; // 자차 지면 높이 (씬 z) — 젖은 노면 판정 + Vec3 hl_pos{0, 0, 0}, hl_dir{1, 0, 0}; + Vec3 ego_pos{0, 0, 0}; // 자차 위치 — 시계(weather_time)와 함께 속도를 추정한다 (빗줄기 기울기) // 전조등 위치·방향 (씬 좌표). 창 루프가 자차 자세로 채운다 // 자차로 쓰는 glTF 모델 번호 (자기 카메라 시점에서 숨긴다) + + void set_spheres(const std::vector& sp) { + spheres_ = sp; + if (sphere_indices_) return; + // UV 구 (48×24). 정점 = 위치(=법선) 단위 구 + std::vector v; + std::vector idx; + const int nu = 48, nv = 24; + for (int j = 0; j <= nv; ++j) + for (int i = 0; i <= nu; ++i) { + float th = 3.14159265f * j / nv, ph = 2 * 3.14159265f * i / nu; + float x = std::sin(th) * std::cos(ph), y = std::sin(th) * std::sin(ph), z = std::cos(th); + v.insert(v.end(), {x, y, z, x, y, z}); + } + for (int j = 0; j < nv; ++j) + for (int i = 0; i < nu; ++i) { + uint32_t a = j * (nu + 1) + i, b = a + nu + 1; + // 바깥에서 볼 때 반시계 (glTF 와 같은 관례, VK_FRONT_FACE_COUNTER_CLOCKWISE 로 앞면) + idx.insert(idx.end(), {a, b, a + 1, a + 1, b, b + 1}); + } + sphere_vb_ = make_buffer(v.size() * 4, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + std::memcpy(sphere_vb_.mapped, v.data(), v.size() * 4); + sphere_slot_ = bl_new_ssbo(); + bl_buffer(0, sphere_slot_, sphere_vb_.buf, 0, VK_WHOLE_SIZE); + sphere_ib_ = make_buffer(idx.size() * 4, VK_BUFFER_USAGE_INDEX_BUFFER_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + std::memcpy(sphere_ib_.mapped, idx.data(), idx.size() * 4); + sphere_indices_ = uint32_t(idx.size()); + } + + // 자차 속도: 날씨 시계가 바뀐 프레임에서 위치 차분 (같은 프레임의 카메라 6대는 같은 값). 순간이동은 버린다. + void update_ego_velocity() { + double dt = double(weather_time) - vel_t_; + if (dt < 0) { // 시계가 거꾸로 (궤적 되감기·다른 프레임 범위) — 다시 시작 + vel_t_ = weather_time; + vel_pos_ = ego_pos; + ego_vel_ = {0, 0, 0}; + return; + } + if (dt <= 1e-4) return; + Vec3 v{(ego_pos.x - vel_pos_.x) / float(dt), (ego_pos.y - vel_pos_.y) / float(dt), (ego_pos.z - vel_pos_.z) / float(dt)}; + float sp = std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z); + if (vel_t_ < 0 || dt > 1.0 || sp > 60.0f) v = {0, 0, 0}; + ego_vel_ = v; + vel_pos_ = ego_pos; + vel_t_ = weather_time; + } + Vec3 ego_vel_{0, 0, 0}, vel_pos_{0, 0, 0}; + double vel_t_ = -1; + + // 한 프레임을 그린다. png_path 가 있으면 저장, present 가 참이면 창에 띄운다. + // pip_slot >= 0: 화면 대신 작은 미리보기 이미지(pip_[slot])로 — 전방 카메라 3대 패널 (창 모드) + // rgb 가 있으면 읽어 온 픽셀을 H×W×3 (행 우선, 위 → 아래) 로 넣는다 — closed-loop 서버의 관측 (--serve) + Timing render(const RenderCam& cam, const std::string& png_path, bool present, int pip_slot = -1, + std::vector* rgb = nullptr) { + ZoneScopedN("render"); + active_mode_ = cam.sensor ? camera_mode : 0; + CameraUBO u{}; + cam.w2c.to_column_major(u.w2c); + u.intr[0] = cam.fx; u.intr[1] = cam.fy; u.intr[2] = cam.cx; u.intr[3] = cam.cy; + u.size[0] = float(width_); u.size[1] = float(height_); u.size[2] = cam.near; u.size[3] = cam.far; + Vec3 cp = cam_position(cam.w2c); + u.campos[0] = cp.x; u.campos[1] = cp.y; u.campos[2] = cp.z; u.campos[3] = cam.clip_z; + u.counts[0] = n_; u.counts[1] = npow2_; u.counts[2] = sh_degree_; + cam.obj_xform.to_column_major(u.obj_xform); + u.obj[0] = obj_start_; + u.obj[1] = cam.show_obj ? 1u : 0u; + u.objs[0] = obj_gauss_slot_; u.objs[1] = obj_ids_slot_; u.objs[2] = obj_table_slot_; u.objs[3] = n_obj_; + u.mode[0] = uint32_t(active_mode_); u.mode[1] = g_cfg.sem_vehicle; u.mode[2] = g_cfg.sem_sky; + std::memcpy(&u.mode[3], &g_cfg.depth_max_m, 4); + std::memcpy(ubo_.mapped, &u, sizeof(u)); + { + WeatherUBO w{}; + const WeatherParams& p = weather; + w.p0[0] = p.daylight; w.p0[1] = p.cloud; w.p0[2] = p.rain; w.p0[3] = p.fog; + w.p1[0] = p.wet; w.p1[1] = weather_time; w.p1[2] = p.enabled && active_mode_ != 2 ? (std::getenv("VKGS_WEATHER_DEBUG") ? 2.0f : 1.0f) : 0.0f; // 시맨틱: 날씨 없이 w.p1[3] = ground_z; + w.campos[0] = cp.x; w.campos[1] = cp.y; w.campos[2] = cp.z; + Mat4 c2w = rigid_inverse(cam.w2c); + c2w.at(0, 3) = c2w.at(1, 3) = c2w.at(2, 3) = 0; + c2w.to_column_major(w.c2w); + w.intr[0] = cam.fx; w.intr[1] = cam.fy; w.intr[2] = cam.cx; w.intr[3] = cam.cy; + w.size[0] = float(width_); w.size[1] = float(height_); + bool hl = p.headlights == 1 || (p.headlights == -1 && p.daylight < g_cfg.hl_auto_below); + w.hl_pos[0] = hl_pos.x; w.hl_pos[1] = hl_pos.y; w.hl_pos[2] = hl_pos.z; + w.hl_pos[3] = hl ? g_cfg.hl_intensity : 0.0f; + w.hl_dir[0] = hl_dir.x; w.hl_dir[1] = hl_dir.y; w.hl_dir[2] = hl_dir.z; + w.hl_dir[3] = std::cos(g_cfg.hl_cone_deg * 3.14159265f / 180.0f); // 바깥 원뿔 반각 + w.p2[0] = p.snow; w.p2[1] = p.snow_cover; w.p2[3] = g_cfg.sun_azimuth_deg; + w.p3[0] = g_cfg.cloud_speed[0]; w.p3[1] = g_cfg.cloud_speed[1]; + w.p3[3] = sky_mode_ == SkyMode::Sim ? 1.0f : 0.0f; + update_ego_velocity(); + const auto& fx = g_cfg.fx; + w.p4[0] = ego_vel_.x; w.p4[1] = ego_vel_.y; w.p4[2] = ego_vel_.z; w.p4[3] = fx.exposure_s; + w.p5[0] = fx.wind[0]; w.p5[1] = fx.wind[1]; w.p5[2] = fx.lens_drops; w.p5[3] = fx.puddles; + w.p6[0] = fx.aperture_m; w.p6[1] = fx.fog_height_m; w.p6[2] = fx.box_half_xy; w.p6[3] = fx.box_half_z; + w.p7[0] = float(fx.volumetric_steps); + std::memcpy(weather_ubo_.mapped, &w, sizeof(w)); + } + + uint32_t sc_index = 0; + if (present) { + VkResult r = vkAcquireNextImageKHR(dev_, swapchain_, UINT64_MAX, acquire_sem_, VK_NULL_HANDLE, &sc_index); + if (r == VK_ERROR_OUT_OF_DATE_KHR) { + recreate_swapchain(); + return {}; + } + } + + VK(vkResetCommandBuffer(cmd_, 0)); + VkCommandBufferBeginInfo bi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; + bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + VK(vkBeginCommandBuffer(cmd_, &bi)); + { + ZoneScopedN("record"); + record(cam); + } + if (pip_slot >= 0) record_pip(pip_slot); + if (!png_path.empty() || rgb) record_readback(); + if (present) record_blit_to_swapchain(sc_index); + bool grab = present && !screen_png.empty() && sc_readable_ && + VkDeviceSize(sc_extent_.width) * sc_extent_.height * 4 <= kScreenGrabBytes; + if (grab) record_screen_grab(sc_index); + TracyVkCollect(tracy_vk_, cmd_); // 지난 프레임까지의 GPU 타임스탬프를 Tracy 로 + VK(vkEndCommandBuffer(cmd_)); + + VkSubmitInfo si{VK_STRUCTURE_TYPE_SUBMIT_INFO}; + VkPipelineStageFlags wait_stage = VK_PIPELINE_STAGE_TRANSFER_BIT; + if (present) { + si.waitSemaphoreCount = 1; + si.pWaitSemaphores = &acquire_sem_; + si.pWaitDstStageMask = &wait_stage; + si.signalSemaphoreCount = 1; + si.pSignalSemaphores = &render_sem_[sc_index]; + } + si.commandBufferCount = 1; + si.pCommandBuffers = &cmd_; + VK(vkQueueSubmit(queue_, 1, &si, fence_)); + if (present) { + VkPresentInfoKHR pi{VK_STRUCTURE_TYPE_PRESENT_INFO_KHR}; + pi.waitSemaphoreCount = 1; + pi.pWaitSemaphores = &render_sem_[sc_index]; + pi.swapchainCount = 1; + pi.pSwapchains = &swapchain_; + pi.pImageIndices = &sc_index; + VkResult r = vkQueuePresentKHR(queue_, &pi); + if (r == VK_ERROR_OUT_OF_DATE_KHR || r == VK_SUBOPTIMAL_KHR) resize_pending_ = true; + } + { + ZoneScopedN("gpu wait"); + VK(vkWaitForFences(dev_, 1, &fence_, VK_TRUE, UINT64_MAX)); + } + VK(vkResetFences(dev_, 1, &fence_)); + if (resize_pending_) recreate_swapchain(); + + Timing t; + std::vector ts(rg_executed_.size() + 1); + if (vkGetQueryPoolResults(dev_, rg_queries_, 0, uint32_t(ts.size()), ts.size() * sizeof(uint64_t), ts.data(), + sizeof(uint64_t), VK_QUERY_RESULT_64_BIT) == VK_SUCCESS) { + double p = props_.limits.timestampPeriod * 1e-6; + for (size_t k = 0; k < rg_executed_.size(); ++k) { + vkgs::RGPass* pass = rg_executed_[k]; + pass->gpu_ms = double(ts[k + 1] - ts[k]) * p; + if (pass->name == "preprocess") t.preprocess_ms = pass->gpu_ms; + else if (pass->name == "sort") t.sort_ms = pass->gpu_ms; + else t.raster_ms += pass->gpu_ms; // 나머지 (래스터·날씨 …) + } + } + uint32_t args[4]; + std::memcpy(args, draw_readback_.mapped, sizeof(args)); + t.visible = args[1]; + if (rgb) { + ZoneScopedN("rgb copy"); + const uint8_t* src = static_cast(readback_.mapped); + size_t n = size_t(width_) * height_; + rgb->resize(n * 3); + uint8_t* dst = rgb->data(); + for (size_t i = 0; i < n; ++i, src += 4, dst += 3) dst[0] = src[0], dst[1] = src[1], dst[2] = src[2]; + } + if (grab) { // 창 화면 그대로 (UI 패널 · 미리보기 줄 포함). 스왑체인이 BGRA 면 RGBA 로 + fs::create_directories(fs::path(screen_png).parent_path()); + int w = int(sc_extent_.width), h = int(sc_extent_.height); + const uint8_t* src = static_cast(screen_rb_.mapped); + std::vector px(src, src + size_t(w) * h * 4); + bool bgra = sc_format_ == VK_FORMAT_B8G8R8A8_UNORM || sc_format_ == VK_FORMAT_B8G8R8A8_SRGB; + while (int(png_jobs_.size()) >= std::max(1, g_cfg.png_writers)) finish_png(); + png_jobs_.push_back(std::async(std::launch::async, [path = screen_png, w, h, bgra, px = std::move(px)]() mutable { + for (size_t i = 0; i < px.size(); i += 4) { + if (bgra) std::swap(px[i], px[i + 2]); + px[i + 3] = 255; + } + if (!stbi_write_png(path.c_str(), w, h, 4, px.data(), w * 4)) + throw std::runtime_error("PNG 저장 실패: " + path); + })); + } + if (present) screen_png.clear(); // 미리보기 렌더(present 아님)는 건드리지 않는다 + if (!png_path.empty()) { + // 읽어 온 픽셀을 복사해 백그라운드에서 인코딩 — 다음 프레임 렌더와 겹친다 (in-flight 는 png_writers 개까지) + ZoneScopedN("png queue"); + fs::create_directories(fs::path(png_path).parent_path()); + std::vector px(static_cast(readback_.mapped), + static_cast(readback_.mapped) + size_t(width_) * height_ * 4); + while (int(png_jobs_.size()) >= std::max(1, g_cfg.png_writers)) finish_png(); + int w = width_, h = height_; + png_jobs_.push_back(std::async(std::launch::async, [png_path, w, h, px = std::move(px)]() { + ZoneScopedN("png encode"); + if (!stbi_write_png(png_path.c_str(), w, h, 4, px.data(), w * 4)) + throw std::runtime_error("PNG 저장 실패: " + png_path); + })); + } + return t; + } + + // 가장 오래된 PNG 인코딩을 기다린다 (오류는 여기서 다시 던진다) + void finish_png() { + if (png_jobs_.empty()) return; + std::future f = std::move(png_jobs_.front()); + png_jobs_.pop_front(); // 먼저 꺼낸다 — get() 이 던져도 다음 대기가 빈 future 를 만나지 않게 + f.get(); + } + void flush_png() { while (!png_jobs_.empty()) finish_png(); } + std::deque> png_jobs_; + + std::string screen_png; // 다음 render() 에서 창 화면(UI 포함)을 이 PNG 로 (--record-screen). 한 번 쓰면 지워진다 + int active_mode_ = 0; // 지금 그리는 시점의 모드 (render 가 cam.sensor 로 정한다) + int camera_mode = 0; // 0 RGB · 1 깊이 · 2 시맨틱 · 3 흑백 (뷰어 '카메라' 버튼 · F3, camera_mode.comp) + int width() const { return width_; } + int height() const { return height_; } + +private: + // ---------------------------------------------------------- 초기화 + void create_instance() { + // 입력기(ibus-hangul XIM)를 끈다: GLFW 는 X11 키 이벤트를 XFilterEvent 로 입력기에 먼저 넘기는데, 한글 입력 + // 상태면 입력기가 먹어 glfwGetKey 가 끝내 눌림을 못 본다 — 창이 포커스를 가져도 차가 안 움직이던 원인 (2026-10-12). + // 뷰어는 한글 텍스트 입력이 필요 없다 + if (want_window_) setenv("XMODIFIERS", "@im=none", 1); + if (want_window_ && !glfwInit()) throw std::runtime_error("glfwInit 실패 (DISPLAY 확인)"); + VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO}; + app.pApplicationName = "vkgs_viewer"; + app.apiVersion = VK_API_VERSION_1_2; + std::vector exts; + if (want_window_) { + uint32_t n = 0; + const char** e = glfwGetRequiredInstanceExtensions(&n); + exts.assign(e, e + n); + } + std::vector layers; + if (const char* v = std::getenv("VKGS_VALIDATION"); v && v[0] == '1') + layers.push_back("VK_LAYER_KHRONOS_validation"); + VkInstanceCreateInfo ci{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO}; + ci.pApplicationInfo = &app; + ci.enabledExtensionCount = uint32_t(exts.size()); + ci.ppEnabledExtensionNames = exts.data(); + ci.enabledLayerCount = uint32_t(layers.size()); + ci.ppEnabledLayerNames = layers.data(); + VK(vkCreateInstance(&ci, nullptr, &inst_)); + } + + void create_window() { + glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); + window_ = glfwCreateWindow(width_, height_, "vkgs_viewer", nullptr, nullptr); + if (!window_) throw std::runtime_error("창 생성 실패"); + glfwFocusWindow(window_); // 셸 백그라운드에서 띄우면 창 관리자가 포커스를 안 줘 키가 터미널로 갔다 (2026-10-12) + VK(glfwCreateWindowSurface(inst_, window_, nullptr, &surface_)); + } + + void pick_device() { + uint32_t n = 0; + vkEnumeratePhysicalDevices(inst_, &n, nullptr); + if (!n) throw std::runtime_error("Vulkan 장치가 없음 — VK_DRIVER_FILES 확인 (scripts/local_env.sh)"); + std::vector devs(n); + vkEnumeratePhysicalDevices(inst_, &n, devs.data()); + for (auto d : devs) { + VkPhysicalDeviceProperties p; + vkGetPhysicalDeviceProperties(d, &p); + uint32_t qn = 0; + vkGetPhysicalDeviceQueueFamilyProperties(d, &qn, nullptr); + std::vector qs(qn); + vkGetPhysicalDeviceQueueFamilyProperties(d, &qn, qs.data()); + for (uint32_t i = 0; i < qn; ++i) { + bool gfx = (qs[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) && (qs[i].queueFlags & VK_QUEUE_COMPUTE_BIT); + VkBool32 pres = VK_TRUE; + if (surface_) vkGetPhysicalDeviceSurfaceSupportKHR(d, i, surface_, &pres); + if (gfx && pres && (!phys_ || p.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU)) { + phys_ = d; + qfam_ = i; + props_ = p; + break; + } + } + } + if (!phys_) throw std::runtime_error("그래픽+컴퓨트 큐가 있는 장치가 없음"); + vkGetPhysicalDeviceMemoryProperties(phys_, &memprops_); + } + + void create_device() { + float prio = 1.0f; + VkDeviceQueueCreateInfo qi{VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO}; + qi.queueFamilyIndex = qfam_; + qi.queueCount = 1; + qi.pQueuePriorities = &prio; + std::vector exts; + if (want_window_) exts.push_back(VK_KHR_SWAPCHAIN_EXTENSION_NAME); + // 맵 선을 2~3 px 로 그리려면 wideLines 가 필요하다 (없으면 1 px 로 폴백) + VkPhysicalDeviceFeatures have{}, want{}; + vkGetPhysicalDeviceFeatures(phys_, &have); + want.wideLines = have.wideLines; + // IBL 큐브맵이 BC6H (KTX2) 라 BC 텍스처 압축이 필요하다 + want.textureCompressionBC = have.textureCompressionBC; + bc_ok_ = have.textureCompressionBC; + { // GPU radix 정렬의 scatter 는 컴퓨트 subgroup ballot 과 크기 16 이상이 필요하다 (gs_radix_scatter.comp) + VkPhysicalDeviceSubgroupProperties sp{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SUBGROUP_PROPERTIES}; + VkPhysicalDeviceProperties2 p2{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2}; + p2.pNext = &sp; + vkGetPhysicalDeviceProperties2(phys_, &p2); + radix_ok_ = (sp.supportedStages & VK_SHADER_STAGE_COMPUTE_BIT) && + (sp.supportedOperations & VK_SUBGROUP_FEATURE_BALLOT_BIT) && sp.subgroupSize >= 16 && + sp.subgroupSize <= 256 && !env_flag("VKGS_BITONIC"); + } + want.samplerAnisotropy = have.samplerAnisotropy; // glTF 텍스처 (비스듬한 차체·노면) + aniso_ok_ = have.samplerAnisotropy; + wide_lines_ = have.wideLines; + // 색(블렌드)과 거리 누적(덧셈) 첨부의 블렌드가 다르다 + if (!have.independentBlend) throw std::runtime_error("independentBlend 미지원 장치"); + want.independentBlend = VK_TRUE; + // 형식 없는 storage image — 렌더 타겟 형식(16/32비트)을 셰이더 재컴파일 없이 JSON 으로 고르게 + formatless_ok_ = have.shaderStorageImageReadWithoutFormat && have.shaderStorageImageWriteWithoutFormat; + want.shaderStorageImageReadWithoutFormat = have.shaderStorageImageReadWithoutFormat; + want.shaderStorageImageWriteWithoutFormat = have.shaderStorageImageWriteWithoutFormat; + // bindless (descriptor indexing, Vulkan 1.2 코어): 전역 descriptor set 하나에 리소스 배열을 두고 번호로 고른다 + VkPhysicalDeviceVulkan12Features h12{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES}; + VkPhysicalDeviceFeatures2 h2{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2}; + h2.pNext = &h12; + vkGetPhysicalDeviceFeatures2(phys_, &h2); + if (!h12.runtimeDescriptorArray || !h12.descriptorBindingPartiallyBound || + !h12.descriptorBindingSampledImageUpdateAfterBind || !h12.descriptorBindingStorageImageUpdateAfterBind || + !h12.descriptorBindingStorageBufferUpdateAfterBind || !h12.descriptorBindingUniformBufferUpdateAfterBind) + throw std::runtime_error("이 GPU 는 bindless(descriptor indexing) 를 지원하지 않음"); + VkPhysicalDeviceVulkan12Features e12{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES}; + e12.descriptorIndexing = VK_TRUE; + e12.runtimeDescriptorArray = VK_TRUE; + e12.descriptorBindingPartiallyBound = VK_TRUE; + e12.descriptorBindingSampledImageUpdateAfterBind = VK_TRUE; + e12.descriptorBindingStorageImageUpdateAfterBind = VK_TRUE; + e12.descriptorBindingStorageBufferUpdateAfterBind = VK_TRUE; + e12.descriptorBindingUniformBufferUpdateAfterBind = VK_TRUE; + e12.shaderSampledImageArrayNonUniformIndexing = h12.shaderSampledImageArrayNonUniformIndexing; + VkPhysicalDeviceFeatures2 e2{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2}; + e2.features = want; + e2.pNext = &e12; + VkDeviceCreateInfo ci{VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO}; + ci.pNext = &e2; + ci.queueCreateInfoCount = 1; + ci.pQueueCreateInfos = &qi; + ci.enabledExtensionCount = uint32_t(exts.size()); + ci.ppEnabledExtensionNames = exts.data(); + VK(vkCreateDevice(phys_, &ci, nullptr, &dev_)); + vkGetDeviceQueue(dev_, qfam_, 0, &queue_); + } + + uint32_t mem_type(uint32_t bits, VkMemoryPropertyFlags want) const { + for (uint32_t i = 0; i < memprops_.memoryTypeCount; ++i) + if ((bits & (1u << i)) && (memprops_.memoryTypes[i].propertyFlags & want) == want) return i; + throw std::runtime_error("맞는 메모리 타입이 없음"); + } + + Buffer make_buffer(VkDeviceSize size, VkBufferUsageFlags usage, VkMemoryPropertyFlags props) { + Buffer b; + b.size = size; + VkBufferCreateInfo ci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + ci.size = size; + ci.usage = usage; + VK(vkCreateBuffer(dev_, &ci, nullptr, &b.buf)); + VkMemoryRequirements req; + vkGetBufferMemoryRequirements(dev_, b.buf, &req); + VkMemoryAllocateInfo ai{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO}; + ai.allocationSize = req.size; + ai.memoryTypeIndex = mem_type(req.memoryTypeBits, props); + VK(vkAllocateMemory(dev_, &ai, nullptr, &b.mem)); + VK(vkBindBufferMemory(dev_, b.buf, b.mem, 0)); + if (props & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) VK(vkMapMemory(dev_, b.mem, 0, size, 0, &b.mapped)); + return b; + } + + void one_shot(const std::function& fn) { + VkCommandBufferAllocateInfo ai{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO}; + ai.commandPool = pool_; + ai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + ai.commandBufferCount = 1; + VkCommandBuffer c; + VK(vkAllocateCommandBuffers(dev_, &ai, &c)); + VkCommandBufferBeginInfo bi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; + bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + VK(vkBeginCommandBuffer(c, &bi)); + fn(c); + VK(vkEndCommandBuffer(c)); + VkSubmitInfo si{VK_STRUCTURE_TYPE_SUBMIT_INFO}; + si.commandBufferCount = 1; + si.pCommandBuffers = &c; + VK(vkQueueSubmit(queue_, 1, &si, VK_NULL_HANDLE)); + VK(vkQueueWaitIdle(queue_)); + vkFreeCommandBuffers(dev_, pool_, 1, &c); + } + + void upload_to(Buffer& dst, const void* data, VkDeviceSize size, VkDeviceSize dst_offset = 0) { + // 256 MB 씩 나눠 올린다 — 가우시안 200만 개면 480 MB 라 스테이징을 통째로 잡지 않는다 + const VkDeviceSize chunk = 256ull << 20; + Buffer staging = make_buffer(std::min(size, chunk), VK_BUFFER_USAGE_TRANSFER_SRC_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + for (VkDeviceSize off = 0; off < size; off += chunk) { + VkDeviceSize n = std::min(chunk, size - off); + std::memcpy(staging.mapped, static_cast(data) + off, n); + one_shot([&](VkCommandBuffer c) { + VkBufferCopy r{0, dst_offset + off, n}; + vkCmdCopyBuffer(c, staging.buf, dst.buf, 1, &r); + }); + } + vkDestroyBuffer(dev_, staging.buf, nullptr); + vkFreeMemory(dev_, staging.mem, nullptr); + } + + // RGBA8 텍스처 + blit 으로 mip 체인 + Tex make_texture_rgba8(int w, int h, const uint8_t* rgba, bool srgb) { + Tex t; + t.levels = 1; + while ((std::max(w, h) >> t.levels) > 0) ++t.levels; + VkFormat fmt = srgb ? VK_FORMAT_R8G8B8A8_SRGB : VK_FORMAT_R8G8B8A8_UNORM; + VkImageCreateInfo ci{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO}; + ci.imageType = VK_IMAGE_TYPE_2D; + ci.format = fmt; + ci.extent = {uint32_t(w), uint32_t(h), 1}; + ci.mipLevels = t.levels; + ci.arrayLayers = 1; + ci.samples = VK_SAMPLE_COUNT_1_BIT; + ci.tiling = VK_IMAGE_TILING_OPTIMAL; + ci.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; + VK(vkCreateImage(dev_, &ci, nullptr, &t.img)); + VkMemoryRequirements req; + vkGetImageMemoryRequirements(dev_, t.img, &req); + VkMemoryAllocateInfo ai{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO}; + ai.allocationSize = req.size; + ai.memoryTypeIndex = mem_type(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + VK(vkAllocateMemory(dev_, &ai, nullptr, &t.mem)); + VK(vkBindImageMemory(dev_, t.img, t.mem, 0)); + VkDeviceSize n = VkDeviceSize(w) * h * 4; + Buffer staging = make_buffer(n, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + std::memcpy(staging.mapped, rgba, n); + one_shot([&](VkCommandBuffer c) { + auto bar = [&](uint32_t mip, VkImageLayout from, VkImageLayout to, VkAccessFlags sa, VkAccessFlags da, + VkPipelineStageFlags ss, VkPipelineStageFlags ds) { + VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER}; + b.oldLayout = from; + b.newLayout = to; + b.srcAccessMask = sa; + b.dstAccessMask = da; + b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + b.image = t.img; + b.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, mip, 1, 0, 1}; + vkCmdPipelineBarrier(c, ss, ds, 0, 0, nullptr, 0, nullptr, 1, &b); + }; + for (uint32_t m = 0; m < t.levels; ++m) + bar(m, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT, + VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + VkBufferImageCopy r{}; + r.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + r.imageExtent = {uint32_t(w), uint32_t(h), 1}; + vkCmdCopyBufferToImage(c, staging.buf, t.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &r); + for (uint32_t m = 1; m < t.levels; ++m) { + bar(m - 1, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT); + VkImageBlit bl{}; + bl.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, m - 1, 0, 1}; + bl.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, m, 0, 1}; + bl.srcOffsets[1] = {std::max(1, w >> (m - 1)), std::max(1, h >> (m - 1)), 1}; + bl.dstOffsets[1] = {std::max(1, w >> m), std::max(1, h >> m), 1}; + vkCmdBlitImage(c, t.img, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, t.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + 1, &bl, VK_FILTER_LINEAR); + bar(m - 1, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + } + bar(t.levels - 1, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + }); + vkDestroyBuffer(dev_, staging.buf, nullptr); + vkFreeMemory(dev_, staging.mem, nullptr); + VkImageViewCreateInfo vi{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO}; + vi.image = t.img; + vi.viewType = VK_IMAGE_VIEW_TYPE_2D; + vi.format = fmt; + vi.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, t.levels, 0, 1}; + VK(vkCreateImageView(dev_, &vi, nullptr, &t.view)); + return t; + } + + // KTX2 (초압축 없음) → 샘플링용 이미지. 큐브맵(면 6)과 2D 를 처리한다. nusim/io/ktx2.py 와 짝. + Tex load_texture(const std::string& path) { + auto b = read_file(path); + static const unsigned char ID[12] = {0xAB, 'K', 'T', 'X', ' ', '2', '0', 0xBB, '\r', '\n', 0x1A, '\n'}; + if (b.size() < 80 || std::memcmp(b.data(), ID, 12) != 0) throw std::runtime_error(path + ": KTX2 아님"); + auto u32 = [&](size_t o) { uint32_t x; std::memcpy(&x, b.data() + o, 4); return x; }; + auto u64 = [&](size_t o) { uint64_t x; std::memcpy(&x, b.data() + o, 8); return x; }; + VkFormat fmt = VkFormat(u32(12)); + uint32_t w = u32(20), h = u32(24), faces = u32(36), levels = std::max(1u, u32(40)), sc = u32(44); + if (sc != 0) throw std::runtime_error(path + ": 초압축 미지원"); + Tex t; + t.levels = levels; + VkImageCreateInfo ci{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO}; + ci.flags = faces == 6 ? VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT : 0; + ci.imageType = VK_IMAGE_TYPE_2D; + ci.format = fmt; + ci.extent = {w, h, 1}; + ci.mipLevels = levels; + ci.arrayLayers = faces; + ci.samples = VK_SAMPLE_COUNT_1_BIT; + ci.tiling = VK_IMAGE_TILING_OPTIMAL; + ci.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT; + VK(vkCreateImage(dev_, &ci, nullptr, &t.img)); + VkMemoryRequirements req; + vkGetImageMemoryRequirements(dev_, t.img, &req); + VkMemoryAllocateInfo ai{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO}; + ai.allocationSize = req.size; + ai.memoryTypeIndex = mem_type(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + VK(vkAllocateMemory(dev_, &ai, nullptr, &t.mem)); + VK(vkBindImageMemory(dev_, t.img, t.mem, 0)); + + Buffer staging = make_buffer(b.size(), VK_BUFFER_USAGE_TRANSFER_SRC_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + std::memcpy(staging.mapped, b.data(), b.size()); + std::vector regions; + for (uint32_t m = 0; m < levels; ++m) { + size_t li = 80 + 24 * m; + uint64_t off = u64(li), len = u64(li + 8); + uint64_t per = len / faces; + for (uint32_t f = 0; f < faces; ++f) { + VkBufferImageCopy r{}; + r.bufferOffset = off + f * per; + r.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, m, f, 1}; + r.imageExtent = {std::max(1u, w >> m), std::max(1u, h >> m), 1}; + regions.push_back(r); + } + } + one_shot([&](VkCommandBuffer c) { + VkImageMemoryBarrier br{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER}; + br.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + br.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + br.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + br.srcQueueFamilyIndex = br.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + br.image = t.img; + br.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, levels, 0, faces}; + vkCmdPipelineBarrier(c, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, + 0, nullptr, 1, &br); + vkCmdCopyBufferToImage(c, staging.buf, t.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + uint32_t(regions.size()), regions.data()); + br.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + br.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + br.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + br.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + vkCmdPipelineBarrier(c, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, + nullptr, 0, nullptr, 1, &br); + }); + vkDestroyBuffer(dev_, staging.buf, nullptr); + vkFreeMemory(dev_, staging.mem, nullptr); + + VkImageViewCreateInfo vi{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO}; + vi.image = t.img; + vi.viewType = faces == 6 ? VK_IMAGE_VIEW_TYPE_CUBE : VK_IMAGE_VIEW_TYPE_2D; + vi.format = fmt; + vi.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, levels, 0, faces}; + VK(vkCreateImageView(dev_, &vi, nullptr, &t.view)); + return t; + } + + Image make_image(VkFormat fmt, VkImageUsageFlags usage, bool view) { + Image im; + VkImageCreateInfo ci{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO}; + ci.imageType = VK_IMAGE_TYPE_2D; + ci.format = fmt; + ci.extent = {uint32_t(width_), uint32_t(height_), 1}; + ci.mipLevels = 1; + ci.arrayLayers = 1; + ci.samples = VK_SAMPLE_COUNT_1_BIT; + ci.tiling = VK_IMAGE_TILING_OPTIMAL; + ci.usage = usage; + VK(vkCreateImage(dev_, &ci, nullptr, &im.img)); + VkMemoryRequirements req; + vkGetImageMemoryRequirements(dev_, im.img, &req); + VkMemoryAllocateInfo ai{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO}; + ai.allocationSize = req.size; + ai.memoryTypeIndex = mem_type(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + VK(vkAllocateMemory(dev_, &ai, nullptr, &im.mem)); + VK(vkBindImageMemory(dev_, im.img, im.mem, 0)); + if (view) { + VkImageViewCreateInfo vi{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO}; + vi.image = im.img; + vi.viewType = VK_IMAGE_VIEW_TYPE_2D; + vi.format = fmt; + vi.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + VK(vkCreateImageView(dev_, &vi, nullptr, &im.view)); + } + return im; + } + + void create_targets() { + // 렌더 타겟 형식은 렌더 그래프 JSON 의 resources.<이름>.format 이 정한다 (color·post: rgba16f|rgba32f, + // depth_acc: r16f|r32f). 16비트는 스플랫 블렌딩 대역폭·블렌딩 속도가 두 배다 (Ampere 의 FP32 블렌딩은 반 속도). + // 셰이더는 형식 없는 storage image 로 읽고 쓴다 (shaderStorageImageRead/WriteWithoutFormat). + auto fmt_of = [&](const char* res, const char* def, bool attachment) { + std::string f = def; + const auto& rj = graph_json().at("resources"); + if (rj.contains(res)) f = rj[res].value("format", def); + static const std::map m{{"rgba32f", VK_FORMAT_R32G32B32A32_SFLOAT}, + {"rgba16f", VK_FORMAT_R16G16B16A16_SFLOAT}, + {"rg32f", VK_FORMAT_R32G32_SFLOAT}, + {"rg16f", VK_FORMAT_R16G16_SFLOAT}, + {"r32f", VK_FORMAT_R32_SFLOAT}, + {"r16f", VK_FORMAT_R16_SFLOAT}}; + auto it = m.find(f); + if (it == m.end()) throw std::runtime_error(std::string("렌더 그래프: ") + res + " 의 모르는 형식 " + f); + VkFormatProperties fp; + vkGetPhysicalDeviceFormatProperties(phys_, it->second, &fp); + // 첨부로 쓰는 것(color, depth_acc)만 블렌딩이 필요하다. post 는 storage + blit 원본 + VkFormatFeatureFlags need = VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT | + (attachment ? VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BLEND_BIT : 0); + if ((fp.optimalTilingFeatures & need) != need || !formatless_ok_) + throw std::runtime_error(std::string("렌더 그래프: ") + res + " 형식 " + f + " 를 이 장치가 " + + (attachment ? "블렌딩·storage" : "storage") + " 로 못 씀"); + return it->second; + }; + target_fmt_ = fmt_of("color", "rgba32f", true); + VkFormat post_fmt = fmt_of("post", "rgba32f", false); + depthacc_fmt_ = fmt_of("depth_acc", "rg32f", true); + { + VkFormatProperties fp; + vkGetPhysicalDeviceFormatProperties(phys_, post_fmt, &fp); + if (!(fp.optimalTilingFeatures & VK_FORMAT_FEATURE_BLIT_SRC_BIT)) throw std::runtime_error("post 형식은 blit 원본이 돼야 함"); + linear_blit_ = fp.optimalTilingFeatures & VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT; + } + target_ = make_image(target_fmt_, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | + VK_IMAGE_USAGE_STORAGE_BIT, true); + // 거리 누적 (색 첨부 1) 과 날씨 후처리 결과. 화면·PNG 는 post_ 에서 복사한다 (날씨를 끄면 target 그대로) + depthacc_ = make_image(depthacc_fmt_, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_STORAGE_BIT, true); + post_ = make_image(post_fmt, VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, true); + std::cout << "렌더 타겟 color " << vk_format_name(target_fmt_) << ", 거리 누적 " << vk_format_name(depthacc_fmt_) + << ", post " << vk_format_name(post_fmt) << "\n"; + rgba8_ = make_image(VK_FORMAT_R8G8B8A8_UNORM, VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT, false); + readback_ = make_buffer(VkDeviceSize(width_) * height_ * 4, VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT | + VK_MEMORY_PROPERTY_HOST_CACHED_BIT); + + // 깊이: 3DGS 는 깊이를 안 쓴다 (정렬 블렌딩). 맵 선이 자차 에셋 뒤로 가려지게 하는 데만 쓴다 — + // 자차 박스를 깊이로만 그린 뒤 선을 깊이 테스트한다. + { + VkImageCreateInfo ci{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO}; + ci.imageType = VK_IMAGE_TYPE_2D; + ci.format = VK_FORMAT_D32_SFLOAT; + ci.extent = {uint32_t(width_), uint32_t(height_), 1}; + ci.mipLevels = 1; + ci.arrayLayers = 1; + ci.samples = VK_SAMPLE_COUNT_1_BIT; + ci.tiling = VK_IMAGE_TILING_OPTIMAL; + ci.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT; + VK(vkCreateImage(dev_, &ci, nullptr, &depth_.img)); + VkMemoryRequirements req; + vkGetImageMemoryRequirements(dev_, depth_.img, &req); + VkMemoryAllocateInfo ai{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO}; + ai.allocationSize = req.size; + ai.memoryTypeIndex = mem_type(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + VK(vkAllocateMemory(dev_, &ai, nullptr, &depth_.mem)); + VK(vkBindImageMemory(dev_, depth_.img, depth_.mem, 0)); + VkImageViewCreateInfo vi{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO}; + vi.image = depth_.img; + vi.viewType = VK_IMAGE_VIEW_TYPE_2D; + vi.format = VK_FORMAT_D32_SFLOAT; + vi.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1}; + VK(vkCreateImageView(dev_, &vi, nullptr, &depth_.view)); + } + VkAttachmentDescription atts[3]{}; + VkAttachmentDescription& att = atts[0]; + att.format = target_fmt_; + att.samples = VK_SAMPLE_COUNT_1_BIT; + att.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + att.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + att.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + att.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + att.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + att.finalLayout = VK_IMAGE_LAYOUT_GENERAL; // 날씨 후처리(컴퓨트)가 storage image 로 읽는다 + atts[1] = att; + atts[1].format = depthacc_fmt_; + atts[2].format = VK_FORMAT_D32_SFLOAT; + atts[2].samples = VK_SAMPLE_COUNT_1_BIT; + atts[2].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + atts[2].storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + atts[2].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + atts[2].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + atts[2].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + atts[2].finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + VkAttachmentReference refs[2] = {{0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}, + {1, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}}; + VkAttachmentReference dref{2, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL}; + VkSubpassDescription sub{}; + sub.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + sub.colorAttachmentCount = 2; + sub.pColorAttachments = refs; + sub.pDepthStencilAttachment = &dref; + VkSubpassDependency deps[2]{}; + deps[0].srcSubpass = VK_SUBPASS_EXTERNAL; + deps[0].dstSubpass = 0; + deps[0].srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT; + deps[0].srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_SHADER_READ_BIT; + deps[0].srcStageMask |= VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT; + deps[0].srcAccessMask |= VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + deps[0].dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + deps[0].dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT; + deps[1].srcSubpass = 0; + deps[1].dstSubpass = VK_SUBPASS_EXTERNAL; + deps[1].srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + deps[1].srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + // 뒤 패스: 컴퓨트(날씨)·전송, 그리고 강수 입자 패스(precip)가 정점·프래그먼트에서 거리 누적을 읽는다 + deps[1].dstStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | + VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; + deps[1].dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_SHADER_READ_BIT; + VkRenderPassCreateInfo rp{VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO}; + rp.attachmentCount = 3; + rp.pAttachments = atts; + rp.subpassCount = 1; + rp.pSubpasses = ⊂ + rp.dependencyCount = 2; + rp.pDependencies = deps; + VK(vkCreateRenderPass(dev_, &rp, nullptr, &renderpass_)); + VkFramebufferCreateInfo fb{VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO}; + fb.renderPass = renderpass_; + VkImageView views[3] = {target_.view, depthacc_.view, depth_.view}; + fb.attachmentCount = 3; + fb.pAttachments = views; + fb.width = uint32_t(width_); + fb.height = uint32_t(height_); + fb.layers = 1; + VK(vkCreateFramebuffer(dev_, &fb, nullptr, &framebuffer_)); + } + + // 셰이더 리플렉션 (캐시). 쓰는 binding 이 bindless 레이아웃(set 0, binding 0~5)과 종류까지 맞는지 검사한다 — + // 셰이더와 C++ 가 어긋나면 화면이 조용히 깨지는 대신 시작할 때 이름과 함께 실패한다. + const vkgs::ShaderInfo& reflect(const std::string& name) { + auto it = shader_info_.find(name); + if (it != shader_info_.end()) return it->second; + auto info = vkgs::reflect_spirv(read_file(std::string(VKGS_SPIRV_DIR) + "/" + name + ".spv"), name); + static const VkDescriptorType expect[6] = {VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, + VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}; + for (const auto& b : info.bindings) + if (b.set != 0 || b.binding >= 6 || b.type != expect[b.binding]) + throw std::runtime_error(name + ": set " + std::to_string(b.set) + " binding " + std::to_string(b.binding) + + " (" + vkgs::descriptor_type_name(b.type) + + ") 가 bindless 레이아웃(shaders/bindless.glsl)과 맞지 않음"); + if (info.push_size > push_size_) + throw std::runtime_error(name + ": push constant " + std::to_string(info.push_size) + " B > 레이아웃 " + + std::to_string(push_size_) + " B"); + return shader_info_.emplace(name, std::move(info)).first->second; + } + + VkShaderModule shader(const std::string& name) { + reflect(name); + auto code = read_file(std::string(VKGS_SPIRV_DIR) + "/" + name + ".spv"); + VkShaderModuleCreateInfo ci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO}; + ci.codeSize = code.size(); + ci.pCode = reinterpret_cast(code.data()); + VkShaderModule m; + VK(vkCreateShaderModule(dev_, &ci, nullptr, &m)); + return m; + } + + VkDescriptorSetLayout set_layout(const std::vector& types, VkShaderStageFlags stages) { + std::vector b(types.size()); + for (uint32_t i = 0; i < types.size(); ++i) b[i] = {i, types[i], 1, stages, nullptr}; + VkDescriptorSetLayoutCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + ci.bindingCount = uint32_t(b.size()); + ci.pBindings = b.data(); + VkDescriptorSetLayout l; + VK(vkCreateDescriptorSetLayout(dev_, &ci, nullptr, &l)); + return l; + } + + VkPipelineLayout pipe_layout(VkDescriptorSetLayout* set, uint32_t pc_size, VkShaderStageFlags pc_stages) { + VkPushConstantRange pc{pc_stages, 0, pc_size}; + VkPipelineLayoutCreateInfo ci{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + ci.setLayoutCount = set ? 1 : 0; + ci.pSetLayouts = set; + ci.pushConstantRangeCount = pc_size ? 1 : 0; + ci.pPushConstantRanges = &pc; + VkPipelineLayout l; + VK(vkCreatePipelineLayout(dev_, &ci, nullptr, &l)); + return l; + } + + VkPipeline compute_pipeline(const std::string& name, VkPipelineLayout layout) { + VkComputePipelineCreateInfo ci{VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO}; + ci.stage = {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO}; + ci.stage.stage = VK_SHADER_STAGE_COMPUTE_BIT; + ci.stage.module = shader(name); + ci.stage.pName = "main"; + ci.layout = layout; + VkPipeline p; + VK(vkCreateComputePipelines(dev_, VK_NULL_HANDLE, 1, &ci, nullptr, &p)); + return p; + } + + enum class Depth { Off, Test, WriteOnly, TestWrite }; // WriteOnly = 색 없이 깊이만 (가림막) + enum class Vtx { None, Line, Mesh, Gltf }; + VkPipeline graphics_pipeline(const std::string& vs, const std::string& fs, VkPipelineLayout layout, + VkPrimitiveTopology topo, Vtx vtx, Depth depth = Depth::Off, + VkRenderPass rp = VK_NULL_HANDLE, uint32_t n_color = 2) { + VkPipelineShaderStageCreateInfo st[2]{}; + st[0] = {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO}; + st[0].stage = VK_SHADER_STAGE_VERTEX_BIT; + st[0].module = shader(vs); + st[0].pName = "main"; + st[1] = {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO}; + st[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT; + st[1].module = shader(fs); + st[1].pName = "main"; + + VkPipelineVertexInputStateCreateInfo vi{VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO}; + // 정점 입력 없음: 모든 정점 데이터는 bindless SSBO 에서 셰이더가 직접 읽는다 (vertex pulling). + // Vtx 는 이제 컬링·동적 상태(선 굵기)만 가른다. + VkPipelineInputAssemblyStateCreateInfo ia{VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO}; + ia.topology = topo; + VkPipelineViewportStateCreateInfo vp{VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO}; + vp.viewportCount = 1; + vp.scissorCount = 1; + VkPipelineRasterizationStateCreateInfo rs{VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO}; + rs.polygonMode = VK_POLYGON_MODE_FILL; + rs.cullMode = vtx == Vtx::Mesh ? VK_CULL_MODE_BACK_BIT : VK_CULL_MODE_NONE; + // OpenCV 투영(y 아래)이 Vulkan NDC(y 아래)로 뒤집힘 없이 들어가므로 화면 영상은 실제와 같은 방향이고, + // 바깥에서 반시계(glTF 관례)로 감긴 삼각형은 Vulkan 면적 공식에서 그대로 앞면이다. + // (예전에 CLOCKWISE 로 두었던 건 시험용 구의 인덱스가 실제로는 시계 방향이었기 때문 — 그 탓에 glTF 차량은 + // 전부 뒷면 판정을 받아 법선이 안쪽을 향했고 도장이 크롬처럼 보였다. 2026-10-02 리뷰에서 발견) + rs.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + rs.lineWidth = 1.0f; + VkPipelineMultisampleStateCreateInfo ms{VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO}; + ms.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + VkPipelineColorBlendAttachmentState blend{}; + blend.blendEnable = VK_TRUE; // premultiplied over + blend.srcColorBlendFactor = VK_BLEND_FACTOR_ONE; + blend.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + blend.colorBlendOp = VK_BLEND_OP_ADD; + blend.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + blend.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + blend.alphaBlendOp = VK_BLEND_OP_ADD; + blend.colorWriteMask = depth == Depth::WriteOnly ? 0 : 0xF; + // 첨부 1 = 거리 누적 (R32F, depth_common.glsl). 같은 premultiplied over — 셰이더가 (z·α, ·, ·, α) 를 낸다. + // 맵 선·접지 그림자·가림막은 거리를 쓰지 않는다 (셰이더에 출력이 없다). + if (n_color > 2) throw std::runtime_error("graphics_pipeline: 색 첨부는 2개까지 (blends 배열)"); + VkPipelineColorBlendAttachmentState blends[2] = {blend, blend}; + // 프래그먼트 셰이더가 출력 1(거리 누적)을 쓰는지는 리플렉션으로 안다 + bool depth_out = depth != Depth::WriteOnly && reflect(fs).writes_output(1); + blends[1].colorWriteMask = depth_out ? (VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT) : 0; // (Σzα, Σα) + VkPipelineColorBlendStateCreateInfo cb{VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO}; + cb.attachmentCount = n_color; + cb.pAttachments = blends; + VkDynamicState dyn[3] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR, VK_DYNAMIC_STATE_LINE_WIDTH}; + VkPipelineDynamicStateCreateInfo ds{VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO}; + ds.dynamicStateCount = vtx == Vtx::Line ? 3 : 2; + ds.pDynamicStates = dyn; + + VkGraphicsPipelineCreateInfo ci{VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO}; + ci.stageCount = 2; + ci.pStages = st; + ci.pVertexInputState = &vi; + ci.pInputAssemblyState = &ia; + ci.pViewportState = &vp; + ci.pRasterizationState = &rs; + ci.pMultisampleState = &ms; + ci.pColorBlendState = &cb; + ci.pDynamicState = &ds; + VkPipelineDepthStencilStateCreateInfo dss{VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO}; + dss.depthTestEnable = depth != Depth::Off; + dss.depthWriteEnable = depth == Depth::WriteOnly || depth == Depth::TestWrite; + dss.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL; + ci.pDepthStencilState = &dss; + ci.layout = layout; + ci.renderPass = rp ? rp : renderpass_; + VkPipeline p; + VK(vkCreateGraphicsPipelines(dev_, VK_NULL_HANDLE, 1, &ci, nullptr, &p)); + return p; + } + + // ---------------------------------------------------------- bindless + // 전역 descriptor set 하나 (shaders/bindless.glsl 과 같은 binding·슬롯). 모든 파이프라인이 같은 레이아웃 + // (set 0 + push constant kPushSize B, 모든 단계)을 쓰고, 리소스는 배열 번호로 고른다. 리소스를 더할 때 + // 파이프라인·set 을 새로 만들 필요가 없다 — glTF 재질·텍스처, 씬 여러 개(맵 스트리밍)가 늘어나도 같은 구조. + void create_bindless() { + // push constant 범위 = SPIR-V 디렉터리의 모든 셰이더 중 가장 큰 push 블록 (리플렉션). 16 B 단위로 올림. + push_size_ = 16; + for (const auto& e : std::filesystem::directory_iterator(VKGS_SPIRV_DIR)) + if (e.path().extension() == ".spv") { + auto info = vkgs::reflect_spirv(read_file(e.path().string()), e.path().stem().string()); + push_size_ = std::max(push_size_, (info.push_size + 15u) & ~15u); + } + if (props_.limits.maxPushConstantsSize < push_size_) + throw std::runtime_error("push constant " + std::to_string(push_size_) + " B 를 GPU 가 지원하지 않음"); + const VkDescriptorType types[6] = {VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, + VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, + VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}; + const uint32_t counts[6] = {kMaxSsbo, kMaxUbo, kMaxTex2D, kMaxCube, kMaxImg, kMaxImg}; + VkDescriptorSetLayoutBinding b[6]; + VkDescriptorBindingFlags f[6]; + for (uint32_t k = 0; k < 6; ++k) { + b[k] = {k, types[k], counts[k], kPushStages, nullptr}; + f[k] = VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT | VK_DESCRIPTOR_BINDING_UPDATE_AFTER_BIND_BIT; + } + VkDescriptorSetLayoutBindingFlagsCreateInfo fi{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_BINDING_FLAGS_CREATE_INFO}; + fi.bindingCount = 6; + fi.pBindingFlags = f; + VkDescriptorSetLayoutCreateInfo li{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + li.pNext = &fi; + li.flags = VK_DESCRIPTOR_SET_LAYOUT_CREATE_UPDATE_AFTER_BIND_POOL_BIT; + li.bindingCount = 6; + li.pBindings = b; + VK(vkCreateDescriptorSetLayout(dev_, &li, nullptr, &bl_layout_)); + VkDescriptorPoolSize ps[4] = {{types[0], kMaxSsbo}, {types[1], kMaxUbo}, + {types[2], kMaxTex2D + kMaxCube}, {types[4], 2 * kMaxImg}}; + VkDescriptorPoolCreateInfo pi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; + pi.flags = VK_DESCRIPTOR_POOL_CREATE_UPDATE_AFTER_BIND_BIT; + pi.maxSets = 1; + pi.poolSizeCount = 4; + pi.pPoolSizes = ps; + VK(vkCreateDescriptorPool(dev_, &pi, nullptr, &bl_pool_)); + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = bl_pool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &bl_layout_; + VK(vkAllocateDescriptorSets(dev_, &ai, &bl_set_)); + VkPushConstantRange pr{kPushStages, 0, push_size_}; + VkPipelineLayoutCreateInfo pl{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + pl.setLayoutCount = 1; + pl.pSetLayouts = &bl_layout_; + pl.pushConstantRangeCount = 1; + pl.pPushConstantRanges = ≺ + VK(vkCreatePipelineLayout(dev_, &pl, nullptr, &bl_pipe_layout_)); + // 전역 상수 (SSBO 슬롯 0): IBL·하늘 큐브 번호 + globals_ = make_buffer(sizeof(Globals), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + std::memcpy(globals_.mapped, &globals_cpu_, sizeof(Globals)); + bl_buffer(0, kSsboGlobals, globals_.buf, 0, VK_WHOLE_SIZE); + } + void bl_buffer(uint32_t binding, uint32_t slot, VkBuffer buf, VkDeviceSize off, VkDeviceSize range) { + VkDescriptorBufferInfo bi{buf, off, range}; + VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + w.dstSet = bl_set_; + w.dstBinding = binding; + w.dstArrayElement = slot; + w.descriptorCount = 1; + w.descriptorType = binding == 0 ? VK_DESCRIPTOR_TYPE_STORAGE_BUFFER : VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + w.pBufferInfo = &bi; + vkUpdateDescriptorSets(dev_, 1, &w, 0, nullptr); + } + void bl_image(uint32_t binding, uint32_t slot, VkSampler smp, VkImageView view, VkImageLayout layout) { + VkDescriptorImageInfo ii{smp, view, layout}; + VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + w.dstSet = bl_set_; + w.dstBinding = binding; + w.dstArrayElement = slot; + w.descriptorCount = 1; + w.descriptorType = binding >= 4 ? VK_DESCRIPTOR_TYPE_STORAGE_IMAGE : VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + w.pImageInfo = ⅈ + vkUpdateDescriptorSets(dev_, 1, &w, 0, nullptr); + } + uint32_t bl_new_ssbo() { + if (next_ssbo_ >= kMaxSsbo) throw std::runtime_error("bindless SSBO 슬롯이 모자람"); + return next_ssbo_++; + } + uint32_t bl_new_tex() { + if (next_tex_ >= kMaxTex2D) throw std::runtime_error("bindless 텍스처 슬롯이 모자람"); + return next_tex_++; + } + void update_globals() { std::memcpy(globals_.mapped, &globals_cpu_, sizeof(Globals)); } + + // 파이프라인 레지스트리 + 렌더 그래프 (configs/render_graph.json). 파이프라인 종류·셰이더·상태는 JSON 이, + // 레이아웃·워크그룹 크기·출력 첨부는 리플렉션이 정한다 — 여기에는 파이프라인 이름이 박혀 있지 않다. + void create_pipelines() { + create_bindless(); + pre_layout_ = sort_layout_ = draw_layout_ = line_layout_ = env_layout_ = pbr_layout_ = bl_pipe_layout_; + weather_layout_ = gltf_layout_ = shadow_layout_ = bl_pipe_layout_; + const nlohmann::json& j = graph_json(); + create_graph_images(); + create_graph_targets(); + for (auto& [name, d] : j.at("pipelines").items()) { + if (name.rfind("_doc", 0) == 0) continue; + PipeInfo pi; + if (d.contains("compute")) { + std::string cs = d["compute"]; + pi.pipe = compute_pipeline(cs, bl_pipe_layout_); + pi.compute = true; + pi.shader_stem = std::filesystem::path(cs).stem().string(); + const auto& r = reflect(cs); + for (int k = 0; k < 3; ++k) pi.local_size[k] = r.local_size[k]; + } else { + static const std::map topo{ + {"triangle_list", VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST}, + {"triangle_strip", VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP}, + {"line_list", VK_PRIMITIVE_TOPOLOGY_LINE_LIST}}; + static const std::map depth{{"off", Depth::Off}, {"test", Depth::Test}, + {"test_write", Depth::TestWrite}, + {"write_only", Depth::WriteOnly}}; + Vtx vtx = d.value("cull", "none") == "back" ? Vtx::Mesh + : d.value("line_width", "") == "dynamic" ? Vtx::Line : Vtx::None; + std::string tgt = d.value("target", "scene"); + VkRenderPass rp = VK_NULL_HANDLE; + uint32_t ncol = 2; + if (tgt != "scene") { + const auto& t = targets_.at(tgt); + rp = t.rp; + ncol = t.n_color; + } + pi.pipe = graphics_pipeline(d.at("vertex"), d.at("fragment"), bl_pipe_layout_, + topo.at(d.value("topology", "triangle_list")), vtx, + depth.at(d.value("depth", "off")), rp, ncol); + } + pipes_[name] = pi; + } + auto P = [&](const char* n) { + auto it = pipes_.find(n); + return it == pipes_.end() ? VkPipeline(VK_NULL_HANDLE) : it->second.pipe; + }; + // 실행기들이 쓰는 이름 (그래프 JSON 의 파이프라인 이름과 같아야 한다) + pre_pipe_ = P("gs_preprocess"); + sort_pipe_ = P("gs_sort"); + draw_pipe_ = P("gs_splat"); + line_pipe_ = P("line"); + occluder_pipe_ = P("occluder"); + env_pipe_ = P("env"); + sky_sim_pipe_ = P("sky_sim"); + pbr_pipe_ = P("pbr"); + gltf_opaque_pipe_ = P("gltf_opaque"); + gltf_blend_pipe_ = P("gltf_blend"); + shadow_pipe_ = P("shadow"); + weather_pipe_ = P("weather"); + + rg_.load(j); + rg_.bind_image("color", target_.img); + rg_.bind_image("depth_acc", depthacc_.img); + rg_.bind_image("depth", depth_.img, VK_IMAGE_ASPECT_DEPTH_BIT); + rg_.bind_image("post", post_.img); + for (const auto& [name, gi] : graph_images_) rg_.bind_image(name, gi.img.img); + std::cout << "렌더 그래프 " << g_cfg.render_graph << " (패스 " << rg_.passes().size() << "개, 파이프라인 " + << pipes_.size() << "개)\n"; + register_executors(); + create_graph_resources(); + load_shader_graphs(); + // 설정 passes / --pass 가 마지막 (셰이더 그래프 설정보다 우선) + for (auto& [name, o] : g_cfg.passes.items()) { + if (name.rfind("_doc", 0) == 0) continue; + auto* pass = rg_.find(name); + if (!pass) throw std::runtime_error("설정 passes: 렌더 그래프에 없는 패스 " + name); + pass->enabled = o.value("enabled", pass->enabled); + } + + // 날씨 UBO·이미지 (bindless 고정 슬롯) + weather_ubo_ = make_buffer(sizeof(WeatherUBO), VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + rg_.bind_buffer("weather_ubo", weather_ubo_.buf); + bl_buffer(1, kUboWeather, weather_ubo_.buf, 0, VK_WHOLE_SIZE); + bl_image(4, kImgTarget, VK_NULL_HANDLE, target_.view, VK_IMAGE_LAYOUT_GENERAL); + bl_image(4, kImgPost, VK_NULL_HANDLE, post_.view, VK_IMAGE_LAYOUT_GENERAL); + bl_image(5, kImgDepthAcc, VK_NULL_HANDLE, depthacc_.view, VK_IMAGE_LAYOUT_GENERAL); + // 패스마다 타임스탬프 (앞 + 패스 수) + VkQueryPoolCreateInfo qi{VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO}; + qi.queryType = VK_QUERY_TYPE_TIMESTAMP; + qi.queryCount = uint32_t(rg_.passes().size() + 1); + VK(vkCreateQueryPool(dev_, &qi, nullptr, &rg_queries_)); + } + + // 그래프 JSON 이 "create" 로 선언한 리소스는 엔진이 만든다 (지금은 버퍼: bytes, host_visible, bindless "ssbo"). + // 실행기는 이름으로 찾고, 셰이더는 push 된 bindless 슬롯으로 읽는다 — 새 패스용 버퍼에 C++ 를 고치지 않게. + void create_graph_resources() { + for (const auto& [name, r] : rg_.resources()) { + if (!r.desc.contains("create") || r.type == "image") continue; // 이미지는 create_graph_images + const auto& c = r.desc["create"]; + if (r.type != "buffer") throw std::runtime_error("렌더 그래프: create 는 버퍼·이미지만 — " + name); + // 크기: "bytes" 고정, 또는 "bytes_per" (gaussians | gaussians_pow2) × "stride" — 씬을 올린 뒤에 만든다 + VkDeviceSize bytes = 0; + if (c.contains("bytes")) bytes = c["bytes"].get(); + else { + std::string per = c.at("bytes_per"); + uint64_t n = per == "gaussians" ? std::max(n_, gauss_cap_) : per == "gaussians_pow2" ? npow2_ : 0; + if (per != "gaussians" && per != "gaussians_pow2") + throw std::runtime_error("렌더 그래프: 모르는 bytes_per '" + per + "' (" + name + ")"); + if (n_ == 0) continue; // 씬이 아직 없다 — write_descriptors 에서 다시 + bytes = VkDeviceSize(n) * c.at("stride").get(); + } + auto old = graph_buffers_.find(name); + if (old != graph_buffers_.end()) { + if (old->second.buf.size == bytes) continue; + vkDeviceWaitIdle(dev_); + vkDestroyBuffer(dev_, old->second.buf.buf, nullptr); + vkFreeMemory(dev_, old->second.buf.mem, nullptr); + } + VkMemoryPropertyFlags props = c.value("host_visible", false) + ? VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT + : VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT; + GraphBuffer gb; + if (old != graph_buffers_.end()) gb.slot = old->second.slot; + gb.buf = make_buffer(bytes, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, props); + gb.buf.size = bytes; + if (gb.buf.mapped) std::memset(gb.buf.mapped, 0, bytes); + if (c.value("bindless", "") == "ssbo") { + if (old == graph_buffers_.end()) gb.slot = bl_new_ssbo(); + bl_buffer(0, gb.slot, gb.buf.buf, 0, VK_WHOLE_SIZE); + } + rg_.bind_buffer(name, gb.buf.buf); + graph_buffers_[name] = gb; + } + } + // 그래프 JSON 이 "create" 로 선언한 이미지 (화면 크기, format, bindless 슬롯). 형식 없는 storage 로 셰이더가 읽는다. + void create_graph_images() { + static const std::map fmts{{"rgba32f", VK_FORMAT_R32G32B32A32_SFLOAT}, + {"rgba16f", VK_FORMAT_R16G16B16A16_SFLOAT}, + {"rg32f", VK_FORMAT_R32G32_SFLOAT}, + {"r32f", VK_FORMAT_R32_SFLOAT}, + {"r16f", VK_FORMAT_R16_SFLOAT}}; + for (auto& [name, d] : graph_json().at("resources").items()) { + if (name.rfind("_doc", 0) == 0 || d.value("type", "") != "image" || !d.contains("create")) continue; + VkFormat f = fmts.at(d.at("format").get()); + GraphImage gi; + gi.fmt = f; + gi.img = make_image(f, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_STORAGE_BIT | + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, true); + bool four = f == VK_FORMAT_R32G32B32A32_SFLOAT || f == VK_FORMAT_R16G16B16A16_SFLOAT; + uint32_t& next = four ? next_img4_ : next_img1_; + if (next >= 8) throw std::runtime_error("bindless 이미지 슬롯 부족 (binding 4/5 는 8개)"); + gi.slot = next++; + bl_image(four ? 4 : 5, gi.slot, VK_NULL_HANDLE, gi.img.view, VK_IMAGE_LAYOUT_GENERAL); + graph_images_[name] = gi; + } + } + // 렌더 패스 정의 (그래프 JSON "targets"): 색 첨부 = 그래프 이미지들, 매 패스 지운다. "scene" 은 엔진 기본. + void create_graph_targets() { + if (!graph_json().contains("targets")) return; + for (auto& [name, t] : graph_json()["targets"].items()) { + if (name.rfind("_doc", 0) == 0) continue; + GraphTarget gt; + std::vector atts; + std::vector refs; + std::vector views; + for (const auto& c : t.at("color")) { + const auto& gi = graph_images_.at(c.get()); + VkAttachmentDescription a{}; + a.format = gi.fmt; + a.samples = VK_SAMPLE_COUNT_1_BIT; + a.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + a.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + a.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + a.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + a.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + a.finalLayout = VK_IMAGE_LAYOUT_GENERAL; // 컴퓨트가 storage 로 읽는다 + refs.push_back({uint32_t(atts.size()), VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}); + atts.push_back(a); + views.push_back(gi.img.view); + } + VkSubpassDescription sub{}; + sub.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + sub.colorAttachmentCount = uint32_t(refs.size()); + sub.pColorAttachments = refs.data(); + // 명시적 외부 의존성: 앞 프레임·앞 패스의 읽기(컴퓨트·전송) 뒤에 쓰고, 끝난 뒤 컴퓨트·정점·프래그먼트·전송이 + // 읽는다 (finalLayout GENERAL 전환까지 포함). 암묵적 의존성은 dstAccess 0 이라 날씨 패스의 읽기와 이어지지 않는다 + VkSubpassDependency deps[2]{}; + deps[0].srcSubpass = VK_SUBPASS_EXTERNAL; + deps[0].dstSubpass = 0; + deps[0].srcStageMask = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_TRANSFER_BIT | + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; + deps[0].srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_TRANSFER_READ_BIT; + deps[0].dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + deps[0].dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_COLOR_ATTACHMENT_READ_BIT; + deps[1].srcSubpass = 0; + deps[1].dstSubpass = VK_SUBPASS_EXTERNAL; + deps[1].srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + deps[1].srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + deps[1].dstStageMask = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_TRANSFER_BIT | + VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; + deps[1].dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_TRANSFER_READ_BIT; + VkRenderPassCreateInfo rci{VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO}; + rci.attachmentCount = uint32_t(atts.size()); + rci.pAttachments = atts.data(); + rci.subpassCount = 1; + rci.pSubpasses = ⊂ + rci.dependencyCount = 2; + rci.pDependencies = deps; + VK(vkCreateRenderPass(dev_, &rci, nullptr, >.rp)); + VkFramebufferCreateInfo fci{VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO}; + fci.renderPass = gt.rp; + fci.attachmentCount = uint32_t(views.size()); + fci.pAttachments = views.data(); + fci.width = uint32_t(width_); + fci.height = uint32_t(height_); + fci.layers = 1; + VK(vkCreateFramebuffer(dev_, &fci, nullptr, >.fb)); + gt.n_color = uint32_t(views.size()); + targets_[name] = gt; + } + } + + // 실행기가 push 할 bindless SSBO 슬롯 — 그래프가 만든 버퍼, 또는 엔진 고정 슬롯 + uint32_t ssbo_slot(const std::string& name) const { + auto it = graph_buffers_.find(name); + if (it != graph_buffers_.end()) return it->second.slot; + auto im = graph_images_.find(name); + if (im != graph_images_.end()) return im->second.slot; + static const std::map fixed{ + {"gaussians", kSsboGauss}, {"splats", kSsboSplats}, {"keys", kSsboKeys}, {"draw_args", kSsboDraw}}; + auto f = fixed.find(name); + if (f == fixed.end()) throw std::runtime_error("렌더 그래프: SSBO 슬롯이 없는 리소스 " + name); + return f->second; + } + + // 셰이더 그래프 매개변수 표: compute 파이프라인 X.comp 옆에 X.params.json 이 있으면 (scripts/shader_graph.py 가 만듦) + // 기본값을 매개변수 버퍼에 쓰고, 설정(sim.json shader_graphs.<이름>)의 enabled·params 를 적용한다. + void load_shader_graphs() { + for (const auto& [pname, pi] : pipes_) { + if (!pi.compute) continue; + std::string path = std::string(VKGS_SPIRV_DIR) + "/" + pi.shader_stem + ".params.json"; + if (!std::filesystem::exists(path)) continue; + std::ifstream f(path); + nlohmann::json t = nlohmann::json::parse(f); + ShaderGraphInfo sg; + sg.name = t.value("graph", pi.shader_stem); + sg.resource = t.value("resource", ""); + auto gb = graph_buffers_.find(sg.resource); + if (gb == graph_buffers_.end() || !gb->second.buf.mapped) + throw std::runtime_error("셰이더 그래프 " + sg.name + ": 매개변수 버퍼 '" + sg.resource + + "' 가 렌더 그래프에 host_visible create 로 없음"); + for (const auto& p : t["params"]) { + ShaderGraphParam sp{p["name"], p["index"], p["default"], p["min"], p["max"]}; + if ((sp.index + 1) * sizeof(float) > gb->second.buf.size) + throw std::runtime_error("셰이더 그래프 " + sg.name + ": 매개변수 버퍼가 작음"); + sg.params.push_back(sp); + } + const nlohmann::json* ov = nullptr; + if (g_cfg.shader_graphs.contains(sg.name)) ov = &g_cfg.shader_graphs[sg.name]; + for (auto& sp : sg.params) { + float v = sp.def; + if (ov && ov->contains("params") && (*ov)["params"].contains(sp.name)) v = (*ov)["params"][sp.name]; + set_graph_param(gb->second, sp, v); + } + for (auto& pass : rg_.passes()) + if (pass.params.value("pipeline", "") == pname) { + if (ov && ov->contains("enabled")) pass.enabled = (*ov)["enabled"]; + shader_graph_of_pass_[pass.name] = sg.name; + } + std::cout << "셰이더 그래프 " << sg.name << " (매개변수 " << sg.params.size() << "개, 버퍼 " << sg.resource << ")\n"; + shader_graphs_[sg.name] = std::move(sg); + } + } + static void set_graph_param(GraphBuffer& gb, const ShaderGraphParam& p, float v) { + static_cast(gb.buf.mapped)[p.index] = v; + } + +public: + // 렌더 그래프 창의 슬라이더용 — 패스가 셰이더 그래프면 그 매개변수들 + ShaderGraphInfo* shader_graph_for_pass(const std::string& pass) { + auto it = shader_graph_of_pass_.find(pass); + return it == shader_graph_of_pass_.end() ? nullptr : &shader_graphs_.at(it->second); + } + float* graph_param(const ShaderGraphInfo& sg, const ShaderGraphParam& p) { + return &static_cast(graph_buffers_.at(sg.resource).buf.mapped)[p.index]; + } +private: + + // 가우시안·스플랫·정렬 키·인다이렉트 인자·카메라 UBO 를 고정 슬롯에 (버퍼를 새로 만들 때마다) + void write_descriptors() { + create_graph_resources(); // 크기가 가우시안 수를 따르는 그래프 버퍼 (keys_tmp 등) + rg_.bind_buffer("gaussians", gauss_.buf); + rg_.bind_buffer("splats", splats_.buf); + rg_.bind_buffer("keys", keys_.buf); + rg_.bind_buffer("draw_args", draw_.buf); + rg_.bind_buffer("draw_readback", draw_readback_.buf); + rg_.bind_buffer("camera", ubo_.buf); + bl_buffer(0, kSsboGauss, gauss_.buf, 0, VK_WHOLE_SIZE); + bl_buffer(0, kSsboSplats, splats_.buf, 0, VK_WHOLE_SIZE); + bl_buffer(0, kSsboKeys, keys_.buf, 0, VK_WHOLE_SIZE); + bl_buffer(0, kSsboDraw, draw_.buf, 0, VK_WHOLE_SIZE); + bl_buffer(1, kUboCamera, ubo_.buf, 0, VK_WHOLE_SIZE); + } + + // ---------------------------------------------------------- 프레임 기록 + static void barrier(VkCommandBuffer c, VkPipelineStageFlags src, VkAccessFlags sa, + VkPipelineStageFlags dst, VkAccessFlags da) { + VkMemoryBarrier b{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + b.srcAccessMask = sa; + b.dstAccessMask = da; + vkCmdPipelineBarrier(c, src, dst, 0, 1, &b, 0, nullptr, 0, nullptr); + } + + static void image_barrier(VkCommandBuffer c, VkImage img, VkImageLayout from, VkImageLayout to, + VkAccessFlags sa, VkAccessFlags da, VkPipelineStageFlags src, + VkPipelineStageFlags dst) { + VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER}; + b.oldLayout = from; + b.newLayout = to; + b.srcAccessMask = sa; + b.dstAccessMask = da; + b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + b.image = img; + b.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + vkCmdPipelineBarrier(c, src, dst, 0, 0, nullptr, 0, nullptr, 1, &b); + } + + // 렌더 그래프를 위상 정렬 순서로 순회하며 패스 실행기를 부른다 (배리어는 그래프가 넣는다). + // 패스마다 끝에 타임스탬프 — 패스별 GPU 시간 (렌더 그래프 창·--graph). + void record(const RenderCam& cam) { + VkCommandBuffer c = cmd_; + vkCmdResetQueryPool(c, rg_queries_, 0, uint32_t(rg_.passes().size() + 1)); + vkCmdWriteTimestamp(c, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, rg_queries_, 0); + // 전역 bindless set 은 프레임마다 두 바인드 지점에 한 번씩만 묶는다 (모든 파이프라인이 같은 레이아웃) + vkCmdBindDescriptorSets(c, VK_PIPELINE_BIND_POINT_COMPUTE, bl_pipe_layout_, 0, 1, &bl_set_, 0, nullptr); + vkCmdBindDescriptorSets(c, VK_PIPELINE_BIND_POINT_GRAPHICS, bl_pipe_layout_, 0, 1, &bl_set_, 0, nullptr); + rg_.begin_frame(); + rg_executed_.clear(); + ++frame_counter_; + rg_.traverse(c, [&](vkgs::RGPass& p) { + auto it = pass_exec_.find(p.kind); + if (it == pass_exec_.end()) throw std::runtime_error("렌더 그래프: 모르는 패스 종류 '" + p.kind + "' (" + p.name + ")"); + { + TracyVkZoneTransient(tracy_vk_, gpu_zone, c, p.name.c_str(), true); + ZoneScopedN("pass"); + ZoneName(p.name.c_str(), p.name.size()); + it->second(c, cam, p); + } + rg_executed_.push_back(&p); + vkCmdWriteTimestamp(c, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, rg_queries_, uint32_t(rg_executed_.size())); + }); + } + + // 패스 종류·그리기 항목 → 실행기. 그래프 JSON 은 이 이름들로 패스를 엮는다. + void register_executors() { + auto pipe_of = [this](const vkgs::RGPass& p) -> PipeInfo& { + auto it = pipes_.find(p.params.at("pipeline").get()); + if (it == pipes_.end()) throw std::runtime_error("렌더 그래프: 패스 " + p.name + " 의 파이프라인이 레지스트리에 없음"); + return it->second; + }; + pass_exec_["none"] = [](VkCommandBuffer, const RenderCam&, vkgs::RGPass&) {}; + pass_exec_["fill_draw_args"] = [this](VkCommandBuffer c, const RenderCam&, vkgs::RGPass& p) { + uint32_t v[4] = {0, 0, 0, 0}; + const auto& vals = p.params.at("values"); + for (size_t k = 0; k < 4 && k < vals.size(); ++k) v[k] = vals[k].get(); + vkCmdUpdateBuffer(c, rg_.buffer(p.params.at("buffer")), 0, sizeof(v), v); + }; + pass_exec_["compute"] = [this, pipe_of](VkCommandBuffer c, const RenderCam&, vkgs::RGPass& p) { + PipeInfo& pi = pipe_of(p); + std::string d = p.params.value("dispatch", "screen"); + uint32_t n[3] = {1, 1, 1}; + if (d == "gaussians_pow2") n[0] = npow2_; + else if (d == "screen") { n[0] = uint32_t(width_); n[1] = uint32_t(height_); } + else throw std::runtime_error("렌더 그래프: 모르는 디스패치 크기 '" + d + "'"); + vkCmdBindPipeline(c, VK_PIPELINE_BIND_POINT_COMPUTE, pi.pipe); + if (p.params.contains("push_slots")) { + // uvec4 slots (graph 리소스의 bindless 슬롯) + vec4 ctx (시각 s, 프레임 번호 비트) + struct { uint32_t slots[4]; float ctx[4]; } pc{}; + int k = 0; + for (const auto& r : p.params["push_slots"]) { + if (k == 4) throw std::runtime_error("렌더 그래프: push_slots 는 4개까지 — " + p.name); + pc.slots[k++] = ssbo_slot(r.get()); + } + pc.ctx[0] = weather_time; + std::memcpy(&pc.ctx[1], &frame_counter_, sizeof(uint32_t)); + vkCmdPushConstants(c, bl_pipe_layout_, kPushStages, 0, sizeof(pc), &pc); + } + vkCmdDispatch(c, (n[0] + pi.local_size[0] - 1) / pi.local_size[0], (n[1] + pi.local_size[1] - 1) / pi.local_size[1], + (n[2] + pi.local_size[2] - 1) / pi.local_size[2]); + }; + pass_exec_["bitonic_sort"] = [this, pipe_of](VkCommandBuffer c, const RenderCam&, vkgs::RGPass& p) { + PipeInfo& pi = pipe_of(p); + vkCmdBindPipeline(c, VK_PIPELINE_BIND_POINT_COMPUTE, pi.pipe); + { // 채우기 단계 (k = 0): 보이는 수 뒤를 '안 보임' 키로 — 전처리는 보이는 것만 앞에 모은다 + uint32_t pc[3] = {0, 0, npow2_}; + vkCmdPushConstants(c, bl_pipe_layout_, kPushStages, 0, sizeof(pc), pc); + vkCmdDispatch(c, (npow2_ + pi.local_size[0] - 1) / pi.local_size[0], 1, 1); + barrier(c, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_ACCESS_SHADER_WRITE_BIT, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT); + } + for (uint32_t k = 2; k <= npow2_; k <<= 1) + for (uint32_t j = k >> 1; j > 0; j >>= 1) { + uint32_t pc[3] = {j, k, npow2_}; + vkCmdPushConstants(c, bl_pipe_layout_, kPushStages, 0, sizeof(pc), pc); + vkCmdDispatch(c, (npow2_ + pi.local_size[0] - 1) / pi.local_size[0], 1, 1); + barrier(c, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_ACCESS_SHADER_WRITE_BIT, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT); + } + }; + // GPU radix 정렬 (gs_radix_*.comp): 자릿수마다 히스토그램 → 스캔 → 안정 scatter, keys ↔ tmp 를 오간다. + // subgroup ballot 이 안 되는 장치는 params.fallback (bitonic) 실행기로. + pass_exec_["radix_sort"] = [this](VkCommandBuffer c, const RenderCam& cam, vkgs::RGPass& p) { + if (!radix_ok_) { + vkgs::RGPass fb = p; + fb.params["pipeline"] = p.params.at("fallback_pipeline"); + return pass_exec_.at(p.params.at("fallback").get())(c, cam, fb); + } + auto pipe = [&](const char* k) { return pipes_.at(p.params.at(k).get()).pipe; }; + uint32_t groups = p.params.value("groups", 512u), bits = p.params.value("key_bits", 32u); + const auto& hist_name = p.params.at("histogram").get(); + if (graph_buffers_.at(hist_name).buf.size < VkDeviceSize(groups) * 256 * 4) + throw std::runtime_error("radix_sort: 히스토그램 버퍼가 groups × 256 × 4 보다 작음"); + struct { uint32_t shift, flip, groups, pad; uint32_t slots[4]; } pc{}; + pc.groups = groups; + pc.slots[0] = ssbo_slot(p.params.at("keys")); + pc.slots[1] = ssbo_slot(p.params.at("tmp")); + pc.slots[2] = ssbo_slot(hist_name); + // 키는 32비트 (gs_preprocess.comp) — 4패스(짝수)라 결과가 keys 에 남는다. 다른 값은 틀린 자릿수를 정렬한다 + if (bits != 32) throw std::runtime_error("radix_sort: key_bits 는 32 만 된다 (gs_preprocess 의 키 폭)"); + auto cc = [&]() { + barrier(c, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_ACCESS_SHADER_WRITE_BIT, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT); + }; + for (uint32_t pass = 0; pass < bits / 8; ++pass) { + pc.shift = 8 * pass; + pc.flip = pass & 1; + vkCmdPushConstants(c, bl_pipe_layout_, kPushStages, 0, sizeof(pc), &pc); + vkCmdBindPipeline(c, VK_PIPELINE_BIND_POINT_COMPUTE, pipe("hist")); + vkCmdDispatch(c, groups, 1, 1); + cc(); + vkCmdBindPipeline(c, VK_PIPELINE_BIND_POINT_COMPUTE, pipe("scan")); + vkCmdDispatch(c, 1, 1, 1); + cc(); + vkCmdBindPipeline(c, VK_PIPELINE_BIND_POINT_COMPUTE, pipe("scatter")); + vkCmdDispatch(c, groups, 1, 1); + cc(); + } + }; + pass_exec_["copy_image"] = [this](VkCommandBuffer c, const RenderCam&, vkgs::RGPass& p) { + VkImageCopy cp{}; + cp.srcSubresource = cp.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + cp.extent = {uint32_t(width_), uint32_t(height_), 1}; + vkCmdCopyImage(c, rg_.image(p.params.at("src")), VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + rg_.image(p.params.at("dst")), VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &cp); + }; + pass_exec_["copy_buffer"] = [this](VkCommandBuffer c, const RenderCam&, vkgs::RGPass& p) { + VkBufferCopy cp{0, 0, p.params.at("bytes").get()}; + vkCmdCopyBuffer(c, rg_.buffer(p.params.at("src")), rg_.buffer(p.params.at("dst")), 1, &cp); + if (p.params.value("host_read", false)) // 펜스 뒤 호스트가 읽는다 — 전송 쓰기를 호스트에 보이게 + barrier(c, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_HOST_BIT, + VK_ACCESS_HOST_READ_BIT); + }; + pass_exec_["raster"] = [this](VkCommandBuffer c, const RenderCam& cam, vkgs::RGPass& p) { + VkRenderPassBeginInfo rp{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO}; + std::string tgt = p.params.value("target", "scene"); + const GraphTarget* gt = tgt == "scene" ? nullptr : &targets_.at(tgt); + rp.renderPass = gt ? gt->rp : renderpass_; + rp.framebuffer = gt ? gt->fb : framebuffer_; + rp.renderArea = {{0, 0}, {uint32_t(width_), uint32_t(height_)}}; + VkClearValue clears[3]{}; + // 거리 누적 (Σzα, Σα) = 0 — 아무것도 없으면 덮임 0 (depth_common.glsl accum_depth → kSkyDepth) + clears[2].depthStencil = {1.0f, 0}; + VkClearValue zero[8]{}; + rp.clearValueCount = gt ? gt->n_color : 3; + rp.pClearValues = gt ? zero : clears; + vkCmdBeginRenderPass(c, &rp, VK_SUBPASS_CONTENTS_INLINE); + VkViewport vpt{0, 0, float(width_), float(height_), 0, 1}; + VkRect2D sc{{0, 0}, {uint32_t(width_), uint32_t(height_)}}; + vkCmdSetViewport(c, 0, 1, &vpt); + vkCmdSetScissor(c, 0, 1, &sc); + for (const auto& d : p.params.at("draws")) { // 그리기 항목도 JSON 순서대로 + auto it = draw_exec_.find(d.get()); + if (it == draw_exec_.end()) throw std::runtime_error("렌더 그래프: 모르는 그리기 항목 '" + d.get() + "'"); + if (!draws_off_.count(it->first) && !(active_mode_ == 2 && it->first == "background") && + !(active_mode_ != 0 && it->first == "lines")) { // 시맨틱: 하늘은 camera_mode.comp 가 · 깊이/시맨틱/흑백 카메라에는 맵 선 없이 + TracyVkZoneTransient(tracy_vk_, draw_zone, c, it->first.c_str(), true); + it->second(c, cam); + } + } + vkCmdEndRenderPass(c); + }; + + draw_exec_["background"] = [this](VkCommandBuffer c, const RenderCam& cam) { + bool sky = sky_mode_ == SkyMode::Learned && sky_loaded_; + bool sim = sky_mode_ == SkyMode::Sim; + if (sim || sky || (ibl_loaded_ && show_env)) { // 배경 하늘 — 스플랫 α < 1 인 곳으로 보인다 + struct { + float c2w[16]; + float intr[4]; + float size[4]; + int32_t idx[4]; + } pc{}; + Mat4 c2w = rigid_inverse(cam.w2c); + c2w.at(0, 3) = c2w.at(1, 3) = c2w.at(2, 3) = 0; + c2w.to_column_major(pc.c2w); + pc.intr[0] = cam.fx; pc.intr[1] = cam.fy; pc.intr[2] = cam.cx; pc.intr[3] = cam.cy; + pc.size[0] = float(width_); pc.size[1] = float(height_); pc.size[2] = sky ? 0.0f : env_lod; + pc.size[3] = sky ? 1.0f : 0.0f; + pc.idx[0] = int32_t(sky ? kCubeSky : kCubeSpec); + vkCmdBindPipeline(c, VK_PIPELINE_BIND_POINT_GRAPHICS, sim ? sky_sim_pipe_ : env_pipe_); + vkCmdPushConstants(c, env_layout_, kPushStages, 0, sizeof(pc), &pc); + vkCmdDraw(c, 3, 1, 0, 0); + } + }; + draw_exec_["spheres"] = [this](VkCommandBuffer c, const RenderCam& cam) { + if (ibl_loaded_ && !spheres_.empty()) { // PBR 메시 — 깊이를 써서 뒤쪽 스플랫을 가린다 + float vpm[16]; + (projection(cam, width_, height_) * cam.w2c).to_column_major(vpm); + Vec3 cp = cam_position(cam.w2c); + vkCmdBindPipeline(c, VK_PIPELINE_BIND_POINT_GRAPHICS, pbr_pipe_); + vkCmdBindIndexBuffer(c, sphere_ib_.buf, 0, VK_INDEX_TYPE_UINT32); + for (const auto& sp : spheres_) { + struct { + float vp[16]; + float cr[4], ar[4], m[4], campos[4]; + int32_t idx[4]; // x = 정점 SSBO 슬롯 (pbr.vert) + } pc{}; + pc.idx[0] = int32_t(sphere_slot_); + std::memcpy(pc.vp, vpm, sizeof(vpm)); + pc.cr[0] = sp.center.x; pc.cr[1] = sp.center.y; pc.cr[2] = sp.center.z; pc.cr[3] = sp.radius; + pc.ar[0] = sp.albedo[0]; pc.ar[1] = sp.albedo[1]; pc.ar[2] = sp.albedo[2]; pc.ar[3] = sp.roughness; + pc.m[0] = sp.metallic; + pc.campos[0] = cp.x; pc.campos[1] = cp.y; pc.campos[2] = cp.z; + vkCmdPushConstants(c, pbr_layout_, kPushStages, 0, sizeof(pc), &pc); + vkCmdDrawIndexed(c, sphere_indices_, 1, 0, 0, 0); + } + } + }; + draw_exec_["gltf_opaque"] = [this](VkCommandBuffer c, const RenderCam& cam) { draw_gltf(c, cam, false); }; + draw_exec_["splats"] = [this](VkCommandBuffer c, const RenderCam& cam) { + vkCmdBindPipeline(c, VK_PIPELINE_BIND_POINT_GRAPHICS, draw_pipe_); + float size[4] = {float(width_), float(height_), cam.near, cam.far}; + vkCmdPushConstants(c, draw_layout_, kPushStages, 0, sizeof(size), size); + vkCmdDrawIndirect(c, draw_.buf, 0, 1, 16); + }; + // 강수 입자 (precip.vert): 비 → 눈 순서로 인스턴스 그리기. 개수 = 세기 × 설정 최대 + draw_exec_["precip"] = [this](VkCommandBuffer c, const RenderCam&) { + if (!weather.enabled) return; + auto pit = pipes_.find("precip"); + if (pit == pipes_.end()) return; + vkCmdBindPipeline(c, VK_PIPELINE_BIND_POINT_GRAPHICS, pit->second.pipe); + const float amount[2] = {std::clamp(weather.rain, 0.0f, 1.0f), std::clamp(weather.snow, 0.0f, 1.0f)}; + const int maxn[2] = {g_cfg.fx.rain_particles, g_cfg.fx.snow_particles}; + for (uint32_t mode = 0; mode < 2; ++mode) { + uint32_t n = uint32_t(amount[mode] * float(maxn[mode])); + if (!n) continue; + uint32_t pc[2] = {mode, n}; + vkCmdPushConstants(c, bl_pipe_layout_, kPushStages, 0, sizeof(pc), pc); + vkCmdDraw(c, 4, n, 0, 0); + } + }; + draw_exec_["shadows"] = [this](VkCommandBuffer c, const RenderCam& cam) { draw_shadows(c, cam); }; + draw_exec_["gltf_translucent"] = [this](VkCommandBuffer c, const RenderCam& cam) { draw_gltf(c, cam, true); }; + draw_exec_["lines"] = [this](VkCommandBuffer c, const RenderCam& cam) { + if (show_lines && (n_static_lines_ || n_dyn_lines_)) { + float vpm[16]; + (projection(cam, width_, height_) * cam.w2c).to_column_major(vpm); + struct { + float vp[16]; + int32_t idx[4]; // x = 정점 SSBO 슬롯 (line.vert) + } lpc{}; + std::memcpy(lpc.vp, vpm, sizeof(vpm)); + // 자차 에셋을 가림막 박스로 깊이에만 그린다 → 그 뒤의 선이 가려진다 + if (cam.show_obj && has_obj()) { + write_occluder(cam.obj_xform); + vkCmdBindPipeline(c, VK_PIPELINE_BIND_POINT_GRAPHICS, occluder_pipe_); + lpc.idx[0] = int32_t(occluder_slot_); + vkCmdPushConstants(c, line_layout_, kPushStages, 0, sizeof(lpc), &lpc); + vkCmdDraw(c, 36, 1, 0, 0); + } + vkCmdBindPipeline(c, VK_PIPELINE_BIND_POINT_GRAPHICS, line_pipe_); + if (n_static_lines_) { + vkCmdSetLineWidth(c, wide_lines_ ? 2.0f : 1.0f); + lpc.idx[0] = int32_t(static_lines_slot_); + vkCmdPushConstants(c, line_layout_, kPushStages, 0, sizeof(lpc), &lpc); + vkCmdDraw(c, n_static_lines_, 1, 0, 0); + } + if (n_dyn_lines_) { + vkCmdSetLineWidth(c, wide_lines_ ? 3.0f : 1.0f); + lpc.idx[0] = int32_t(dyn_lines_slot_); + vkCmdPushConstants(c, line_layout_, kPushStages, 0, sizeof(lpc), &lpc); + vkCmdDraw(c, n_dyn_lines_, 1, 0, 0); + } + } + }; + } + + void draw_gltf(VkCommandBuffer c, const RenderCam& cam, bool translucent) { + if (gltf_models_.empty() || vehicles_.empty()) return; + VkViewport vpt{0, 0, float(width_), float(height_), 0, 1}; + VkRect2D sc{{0, 0}, {uint32_t(width_), uint32_t(height_)}}; + vkCmdSetViewport(c, 0, 1, &vpt); + vkCmdSetScissor(c, 0, 1, &sc); + vkCmdBindPipeline(c, VK_PIPELINE_BIND_POINT_GRAPHICS, translucent ? gltf_blend_pipe_ : gltf_opaque_pipe_); + struct { + float model[16], vp[16], campos[4]; + int32_t idx[4]; // (재질 SSBO 슬롯, 재질 번호) — pbr_gltf.frag + } pc{}; + static_assert(sizeof(pc) == 160, "pbr_gltf push 블록 (리플렉션이 레이아웃 크기를 검사한다)"); + (projection(cam, width_, height_) * cam.w2c).to_column_major(pc.vp); + Vec3 cp = cam_position(cam.w2c); + pc.campos[0] = cp.x; pc.campos[1] = cp.y; pc.campos[2] = cp.z; pc.campos[3] = exposure; + int bound_model = -1; + for (const auto& [mi_model, xf] : vehicles_) { + if (mi_model < 0 || mi_model >= int(gltf_models_.size())) continue; + if (mi_model == ego_model && !cam.show_obj) continue; // 자차는 자기 카메라 시점에서 숨긴다 + if (mi_model != ego_model && !cam.show_vehicles) continue; + const GltfGpu& g = gltf_models_[mi_model]; + if (mi_model != bound_model) { + vkCmdBindIndexBuffer(c, g.ib.buf, 0, VK_INDEX_TYPE_UINT32); + bound_model = mi_model; + } + xf.to_column_major(pc.model); + pc.idx[0] = int32_t(g.mat_slot); + pc.idx[2] = int32_t(g.vb_slot); + pc.idx[1] = -1; // 첫 프리미티브에서 재질 번호를 넣는다 + bool animated = mi_model == ego_model && !ego_parts_.empty(); + int pushed_part = -2; // 아직 부품 변환을 넣지 않았다 (위의 xf 는 강체 그대로) + auto slot = [](const vkgs::GltfPrimitive& p) { return p.part < 0 ? 0 : 1 + 2 * p.part + (p.spins ? 0 : 1); }; + int bound = -2; + for (const auto& p : g.prims) { + int mi = p.material < 0 ? 0 : p.material; + bool tr = p.material >= 0 && g.translucent[mi]; + if (tr != translucent) continue; + bool dirty = false; + if (animated && slot(p) != pushed_part) { + size_t k = size_t(slot(p)); + (xf * (k < ego_parts_.size() ? ego_parts_[k] : Mat4::identity())).to_column_major(pc.model); + pushed_part = slot(p); + dirty = true; + } + if (mi != bound) { + pc.idx[1] = mi; + bound = mi; + dirty = true; + } + if (dirty) vkCmdPushConstants(c, gltf_layout_, kPushStages, 0, sizeof(pc), &pc); + vkCmdDrawIndexed(c, p.index_count, 1, p.first_index, p.vertex_offset, 0); + } + } + } + + void draw_shadows(VkCommandBuffer c, const RenderCam& cam) { + if (!gltf_pipes_ || shadows_.empty() || shadow_opacity <= 0) return; + vkCmdBindPipeline(c, VK_PIPELINE_BIND_POINT_GRAPHICS, shadow_pipe_); + struct { + float vp[16], box[16], params[4]; + } pc{}; + (projection(cam, width_, height_) * cam.w2c).to_column_major(pc.vp); + pc.params[0] = shadow_opacity; + pc.params[1] = 0.10f; // 가장자리 부드러움 (박스 단위) + pc.params[2] = 1.45f; // 쿼드 크기 = 차 바닥 × 1.45 + for (const auto& [mi, box] : shadows_) { + if (mi == ego_model && !cam.show_obj) continue; + if (mi != ego_model && !cam.show_vehicles) continue; + box.to_column_major(pc.box); + vkCmdPushConstants(c, shadow_layout_, kPushStages, 0, + sizeof(pc), &pc); + vkCmdDraw(c, 4, 1, 0, 0); + } + } + + // 전방 카메라 미리보기: post_ 를 1/3 크기 RGBA8 이미지로 줄여 복사 → ImGui 텍스처로 보여 준다 +public: + static constexpr int kPip = 4; // 0~2 전방 카메라 3대, 3 BEV + ImTextureID pip_texture(int k) { + ensure_pip(); + return pip_ids_[k]; + } + ImVec2 pip_size() const { return ImVec2(float(pip_w_), float(pip_h_)); } + +private: + Image pip_[kPip]; + ImTextureID pip_ids_[kPip] = {}; + VkSampler pip_sampler_ = VK_NULL_HANDLE; + int pip_w_ = 0, pip_h_ = 0; + bool pip_init_[kPip] = {}; + void ensure_pip() { + if (pip_sampler_) return; + pip_w_ = width_ / 3; + pip_h_ = height_ / 3; + int fw = width_, fh = height_; + width_ = pip_w_; height_ = pip_h_; // make_image 가 width_/height_ 크기로 만든다 + for (auto& im : pip_) im = make_image(VK_FORMAT_R8G8B8A8_UNORM, VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, true); + width_ = fw; height_ = fh; + VkSamplerCreateInfo si{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO}; + si.magFilter = si.minFilter = VK_FILTER_LINEAR; + si.addressModeU = si.addressModeV = si.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + VK(vkCreateSampler(dev_, &si, nullptr, &pip_sampler_)); + // 아직 그린 적 없는 미리보기도 읽을 수 있게 검은색으로 채워 둔다 + one_shot([&](VkCommandBuffer c) { + VkClearColorValue black{}; + VkImageSubresourceRange rr{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + for (auto& im : pip_) { + image_barrier(c, im.img, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + vkCmdClearColorImage(c, im.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &black, 1, &rr); + image_barrier(c, im.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + } + }); + for (int k = 0; k < kPip; ++k) + pip_ids_[k] = (ImTextureID)ImGui_ImplVulkan_AddTexture(pip_sampler_, pip_[k].view, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + } + void record_pip(int k) { + ensure_pip(); + VkCommandBuffer c = cmd_; + image_barrier(c, pip_[k].img, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT); + VkImageBlit bl{}; + bl.srcSubresource = bl.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + bl.srcOffsets[1] = {width_, height_, 1}; + bl.dstOffsets[1] = {pip_w_, pip_h_, 1}; + vkCmdBlitImage(c, post_.img, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, pip_[k].img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + 1, &bl, linear_blit_ ? VK_FILTER_LINEAR : VK_FILTER_NEAREST); + image_barrier(c, pip_[k].img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + } + + void record_readback() { + VkCommandBuffer c = cmd_; + image_barrier(c, rgba8_.img, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + VkImageBlit bl{}; + bl.srcSubresource = bl.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + bl.srcOffsets[1] = bl.dstOffsets[1] = {width_, height_, 1}; + // float → unorm 변환에서 [0,1] 포화가 일어난다 (학습기의 clamp(0,1) 과 같은 자리) + vkCmdBlitImage(c, post_.img, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, rgba8_.img, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &bl, VK_FILTER_NEAREST); + image_barrier(c, rgba8_.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT); + VkBufferImageCopy r{}; + r.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + r.imageExtent = {uint32_t(width_), uint32_t(height_), 1}; + vkCmdCopyImageToBuffer(c, rgba8_.img, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, readback_.buf, 1, &r); + barrier(c, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_HOST_BIT, + VK_ACCESS_HOST_READ_BIT); + } + + // 창 화면 녹화: UI 를 다 그린 스왑체인 이미지(PRESENT_SRC)를 호스트 버퍼로 복사하고 다시 PRESENT_SRC 로 + static constexpr VkDeviceSize kScreenGrabBytes = VkDeviceSize(3840) * 2160 * 4; + Buffer screen_rb_; + bool sc_readable_ = false; + void record_screen_grab(uint32_t i) { + if (!screen_rb_.buf) + screen_rb_ = make_buffer(kScreenGrabBytes, VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT | + VK_MEMORY_PROPERTY_HOST_CACHED_BIT); + VkCommandBuffer c = cmd_; + VkImage img = sc_images_[i]; + image_barrier(c, img, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + VkBufferImageCopy r{}; + r.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + r.imageExtent = {sc_extent_.width, sc_extent_.height, 1}; + vkCmdCopyImageToBuffer(c, img, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, screen_rb_.buf, 1, &r); + image_barrier(c, img, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + VK_ACCESS_TRANSFER_READ_BIT, 0, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT); + barrier(c, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_HOST_BIT, VK_ACCESS_HOST_READ_BIT); + } + + void record_blit_to_swapchain(uint32_t i) { + VkCommandBuffer c = cmd_; + VkImage img = sc_images_[i]; + image_barrier(c, img, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + // 창 크기가 바뀌어도 렌더 해상도는 그대로 두고 비율을 유지해 가운데에 맞춘다 + float s = std::min(float(sc_extent_.width) / width_, float(sc_extent_.height) / height_); + int dw = int(width_ * s), dh = int(height_ * s); + int ox = (int(sc_extent_.width) - dw) / 2, oy = (int(sc_extent_.height) - dh) / 2; + VkClearColorValue black{}; + VkImageSubresourceRange range{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + vkCmdClearColorImage(c, img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &black, 1, &range); + barrier(c, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_ACCESS_TRANSFER_WRITE_BIT); + VkImageBlit bl{}; + bl.srcSubresource = bl.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + bl.srcOffsets[1] = {width_, height_, 1}; + bl.dstOffsets[0] = {ox, oy, 0}; + bl.dstOffsets[1] = {ox + dw, oy + dh, 1}; + vkCmdBlitImage(c, post_.img, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + 1, &bl, linear_blit_ ? VK_FILTER_LINEAR : VK_FILTER_NEAREST); + ImDrawData* dd = ui_ ? ImGui::GetDrawData() : nullptr; + if (dd && dd->CmdListsCount > 0 && i < ui_fbs_.size()) { // UI 패널 — 화면에만 (PNG·관측에는 안 들어간다) + image_barrier(c, img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT); + VkRenderPassBeginInfo rb{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO}; + rb.renderPass = ui_pass_; + rb.framebuffer = ui_fbs_[i]; + rb.renderArea = {{0, 0}, sc_extent_}; + vkCmdBeginRenderPass(c, &rb, VK_SUBPASS_CONTENTS_INLINE); + ImGui_ImplVulkan_RenderDrawData(dd, c); + vkCmdEndRenderPass(c); // finalLayout = PRESENT_SRC + } else { + image_barrier(c, img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + VK_ACCESS_TRANSFER_WRITE_BIT, 0, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT); + } + } + + // ---------------------------------------------------------- 스왑체인 + void create_swapchain() { + VkSurfaceCapabilitiesKHR caps; + VK(vkGetPhysicalDeviceSurfaceCapabilitiesKHR(phys_, surface_, &caps)); + uint32_t nf = 0; + vkGetPhysicalDeviceSurfaceFormatsKHR(phys_, surface_, &nf, nullptr); + std::vector fmts(nf); + vkGetPhysicalDeviceSurfaceFormatsKHR(phys_, surface_, &nf, fmts.data()); + // UNORM 을 고른다. 렌더 값이 이미 카메라 JPEG 과 같은 감마 공간이라 sRGB 변환을 또 하면 안 된다. + VkSurfaceFormatKHR fmt = fmts[0]; + for (auto f : fmts) + if (f.format == VK_FORMAT_B8G8R8A8_UNORM || f.format == VK_FORMAT_R8G8B8A8_UNORM) fmt = f; + int fw, fh; + glfwGetFramebufferSize(window_, &fw, &fh); + sc_extent_ = caps.currentExtent.width != UINT32_MAX ? caps.currentExtent + : VkExtent2D{uint32_t(fw), uint32_t(fh)}; + if (sc_extent_.width == 0 || sc_extent_.height == 0) return; // 최소화 + VkSwapchainCreateInfoKHR ci{VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR}; + ci.surface = surface_; + ci.minImageCount = std::max(caps.minImageCount, 2u); + if (caps.maxImageCount) ci.minImageCount = std::min(ci.minImageCount, caps.maxImageCount); + ci.imageFormat = fmt.format; + ci.imageColorSpace = fmt.colorSpace; + ci.imageExtent = sc_extent_; + ci.imageArrayLayers = 1; + ci.imageUsage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; // 색 첨부 = UI 패널 + sc_readable_ = (caps.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_SRC_BIT) != 0; // 화면 녹화 (--record-screen) + if (sc_readable_) ci.imageUsage |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT; + ci.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; + sc_format_ = fmt.format; + ci.preTransform = caps.currentTransform; + ci.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; + ci.presentMode = VK_PRESENT_MODE_FIFO_KHR; // vsync — 항상 지원된다 + ci.clipped = VK_TRUE; + VK(vkCreateSwapchainKHR(dev_, &ci, nullptr, &swapchain_)); + uint32_t n = 0; + vkGetSwapchainImagesKHR(dev_, swapchain_, &n, nullptr); + sc_images_.resize(n); + vkGetSwapchainImagesKHR(dev_, swapchain_, &n, sc_images_.data()); + VkSemaphoreCreateInfo si{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO}; + VK(vkCreateSemaphore(dev_, &si, nullptr, &acquire_sem_)); + render_sem_.resize(n); + for (auto& s : render_sem_) VK(vkCreateSemaphore(dev_, &si, nullptr, &s)); + create_ui_targets(); + } + + // UI 패널(ImGui)을 스왑체인 이미지 위에 그리는 렌더 패스·프레임버퍼. 장면은 이미 blit 으로 들어가 있으므로 LOAD. + void create_ui_targets() { + if (!ui_pass_) { + VkAttachmentDescription a{}; + a.format = sc_format_; + a.samples = VK_SAMPLE_COUNT_1_BIT; + a.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD; + a.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + a.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + a.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + a.initialLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + a.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + VkAttachmentReference ref{0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; + VkSubpassDescription sub{}; + sub.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + sub.colorAttachmentCount = 1; + sub.pColorAttachments = &ref; + VkSubpassDependency dep{}; + dep.srcSubpass = VK_SUBPASS_EXTERNAL; + dep.dstSubpass = 0; + dep.srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT; + dep.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + dep.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + dep.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + VkRenderPassCreateInfo rp{VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO}; + rp.attachmentCount = 1; + rp.pAttachments = &a; + rp.subpassCount = 1; + rp.pSubpasses = ⊂ + rp.dependencyCount = 1; + rp.pDependencies = &dep; + VK(vkCreateRenderPass(dev_, &rp, nullptr, &ui_pass_)); + } + for (VkImage im : sc_images_) { + VkImageViewCreateInfo vi{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO}; + vi.image = im; + vi.viewType = VK_IMAGE_VIEW_TYPE_2D; + vi.format = sc_format_; + vi.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + VkImageView v; + VK(vkCreateImageView(dev_, &vi, nullptr, &v)); + sc_views_.push_back(v); + VkFramebufferCreateInfo fb{VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO}; + fb.renderPass = ui_pass_; + fb.attachmentCount = 1; + fb.pAttachments = &v; + fb.width = sc_extent_.width; + fb.height = sc_extent_.height; + fb.layers = 1; + VkFramebuffer f; + VK(vkCreateFramebuffer(dev_, &fb, nullptr, &f)); + ui_fbs_.push_back(f); + } + if (ui_) ImGui_ImplVulkan_SetMinImageCount(uint32_t(std::max(2, sc_images_.size()))); + } + +public: + // ImGui 초기화 (창 모드). 한글 라벨을 위해 나눔고딕을 찾으면 쓴다. + void init_ui() { + if (!want_window_ || ui_) return; + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGuiIO& io = ImGui::GetIO(); + io.IniFilename = nullptr; + ImGui::StyleColorsDark(); + ImGui::GetStyle().WindowRounding = 6.0f; + ImGui::GetStyle().Alpha = 0.92f; + for (const char* f : {"/usr/share/fonts/truetype/nanum/NanumGothic.ttf", + "/usr/share/fonts/truetype/nanum/NanumBarunGothic.ttf"}) + if (std::filesystem::exists(f)) { + io.Fonts->AddFontFromFileTTF(f, 17.0f, nullptr, io.Fonts->GetGlyphRangesKorean()); + break; + } + ImGui_ImplGlfw_InitForVulkan(window_, true); + ImGui_ImplVulkan_InitInfo ii{}; + ii.ApiVersion = VK_API_VERSION_1_2; + ii.Instance = inst_; + ii.PhysicalDevice = phys_; + ii.Device = dev_; + ii.QueueFamily = qfam_; + ii.Queue = queue_; + ii.RenderPass = ui_pass_; + ii.MinImageCount = uint32_t(std::max(2, sc_images_.size())); + ii.ImageCount = ii.MinImageCount; + ii.MSAASamples = VK_SAMPLE_COUNT_1_BIT; + ii.DescriptorPoolSize = 16; + ImGui_ImplVulkan_Init(&ii); + ui_ = true; + } + bool ui_ready() const { return ui_; } + +private: + + void destroy_swapchain() { + if (!swapchain_) return; + for (auto f : ui_fbs_) vkDestroyFramebuffer(dev_, f, nullptr); + for (auto v : sc_views_) vkDestroyImageView(dev_, v, nullptr); + ui_fbs_.clear(); + sc_views_.clear(); + for (auto s : render_sem_) vkDestroySemaphore(dev_, s, nullptr); + render_sem_.clear(); + vkDestroySemaphore(dev_, acquire_sem_, nullptr); + vkDestroySwapchainKHR(dev_, swapchain_, nullptr); + swapchain_ = VK_NULL_HANDLE; + } + + void recreate_swapchain() { + resize_pending_ = false; + int w = 0, h = 0; + glfwGetFramebufferSize(window_, &w, &h); + while (w == 0 || h == 0) { // 최소화 동안 대기 + glfwWaitEvents(); + glfwGetFramebufferSize(window_, &w, &h); + } + vkDeviceWaitIdle(dev_); + destroy_swapchain(); + create_swapchain(); + } + + int width_, height_; + bool want_window_; + GLFWwindow* window_ = nullptr; + VkInstance inst_ = VK_NULL_HANDLE; + VkSurfaceKHR surface_ = VK_NULL_HANDLE; + VkPhysicalDevice phys_ = VK_NULL_HANDLE; + VkPhysicalDeviceProperties props_{}; + VkPhysicalDeviceMemoryProperties memprops_{}; + uint32_t qfam_ = 0; + VkDevice dev_ = VK_NULL_HANDLE; + VkQueue queue_ = VK_NULL_HANDLE; + VkCommandPool pool_ = VK_NULL_HANDLE; + VkCommandBuffer cmd_ = VK_NULL_HANDLE; + TracyVkCtx tracy_vk_ = nullptr; // Tracy GPU 문맥 (VKGS_TRACY 빌드에서만) + VkFence fence_ = VK_NULL_HANDLE; + VkQueryPool queries_ = VK_NULL_HANDLE; + + bool wide_lines_ = false, bc_ok_ = false, aniso_ok_ = false; + VkFormat target_fmt_ = VK_FORMAT_UNDEFINED, depthacc_fmt_ = VK_FORMAT_R32G32_SFLOAT; + bool formatless_ok_ = false; // shaderStorageImageRead/WriteWithoutFormat + nlohmann::json graph_json_; + // 렌더 그래프 JSON (형식·파이프라인·패스) — 한 번 읽어 둔다 + const nlohmann::json& graph_json() { + if (graph_json_.is_null()) { + std::ifstream f(g_cfg.render_graph); + if (!f) throw std::runtime_error("렌더 그래프 파일을 열 수 없음: " + g_cfg.render_graph); + graph_json_ = nlohmann::json::parse(f, nullptr, true, true); + } + return graph_json_; + } + bool linear_blit_ = false; + Image target_, rgba8_, depth_; + Buffer readback_; + VkRenderPass renderpass_ = VK_NULL_HANDLE; + VkFramebuffer framebuffer_ = VK_NULL_HANDLE; + + VkPipelineLayout pre_layout_{}, sort_layout_{}, draw_layout_{}, line_layout_{}; + VkPipeline pre_pipe_{}, sort_pipe_{}, draw_pipe_{}, line_pipe_{}, occluder_pipe_{}, env_pipe_{}, pbr_pipe_{}; + VkPipeline sky_sim_pipe_{}; + VkPipelineLayout env_layout_{}, pbr_layout_{}; + Tex spec_tex_, irr_tex_, lut_tex_; + VkSampler ibl_sampler_ = VK_NULL_HANDLE, sky_sampler_ = VK_NULL_HANDLE; + Tex sky_tex_; + bool sky_loaded_ = false; + bool ibl_loaded_ = false; + Buffer sphere_vb_, sphere_ib_; + // glTF 차량 + struct GltfGpu { // 모델 하나의 GPU 자원 + Buffer vb, ib, ubo; // ubo = 재질 SSBO (bindless 슬롯 mat_slot) + uint32_t mat_slot = 0, vb_slot = 0; + std::vector prims; + std::vector translucent; + std::vector tex; + }; + std::vector gltf_models_; + bool gltf_pipes_ = false; + VkPipelineLayout gltf_layout_{}; + VkPipeline gltf_opaque_pipe_{}, gltf_blend_pipe_{}; + Tex white_tex_; + VkSampler gltf_sampler_ = VK_NULL_HANDLE; + std::vector> vehicles_, shadows_; + Image depthacc_, post_; // 거리 누적 (색 첨부 1), 날씨 후처리 결과 + VkPipelineLayout weather_layout_{}; + VkPipeline weather_pipe_{}; + Buffer weather_ubo_; + // bindless — shaders/bindless.glsl 과 같은 슬롯 번호 + enum : uint32_t { kSsboGlobals = 0, kSsboGauss = 1, kSsboSplats = 2, kSsboKeys = 3, kSsboDraw = 4, kSsboFirstFree = 5 }; + enum : uint32_t { kUboCamera = 0, kUboWeather = 1 }; + enum : uint32_t { kImgTarget = 0, kImgPost = 1, kImgDepthAcc = 0 }; + enum : uint32_t { kCubeSpec = 0, kCubeIrr = 1, kCubeSky = 2 }; + enum : uint32_t { kTexLut = 0, kTexWhite = 1, kTexFirstFree = 2 }; + static constexpr uint32_t kMaxSsbo = 256, kMaxUbo = 16, kMaxTex2D = 4096, kMaxCube = 32, kMaxImg = 8; + uint32_t push_size_ = 0; // 리플렉션으로 정한다 (create_bindless) + struct PipeInfo { + VkPipeline pipe = VK_NULL_HANDLE; + bool compute = false; + uint32_t local_size[3] = {1, 1, 1}; // 컴퓨트 워크그룹 크기 (리플렉션) + std::string shader_stem; // 컴퓨트 셰이더 파일 이름 (셰이더 그래프 매개변수 표를 찾는 데) + }; + std::map pipes_; // 렌더 그래프 JSON 의 파이프라인 레지스트리 + vkgs::RenderGraph rg_; + VkQueryPool rg_queries_ = VK_NULL_HANDLE; + using PassFn = std::function; + using DrawFn = std::function; + std::map pass_exec_; // 패스 종류 → 실행기 + std::map draw_exec_; // 래스터 패스의 그리기 항목 → 실행기 + std::vector rg_executed_; // 이번 프레임에 실행된 패스 (타임스탬프 순서) + std::set draws_off_; // 렌더 그래프 창에서 끈 그리기 항목 + std::map graph_buffers_; // 그래프 JSON 의 create 리소스 + std::map graph_images_; // 그래프 JSON 의 create 이미지 + std::map targets_; // 그래프 JSON 의 targets (렌더 패스) + uint32_t next_img4_ = 2, next_img1_ = 1; // binding 4/5 다음 빈 슬롯 (0·1 은 엔진 고정) + std::map shader_graphs_; // 셰이더 그래프 이름 → 매개변수 표 + std::map shader_graph_of_pass_; // 패스 이름 → 셰이더 그래프 이름 + uint32_t frame_counter_ = 0; // 노이즈 씨앗 등 + bool radix_ok_ = false; // subgroup ballot (컴퓨트) + 크기 16~ → GPU radix 정렬 +public: + vkgs::RenderGraph& graph() { return rg_; } + const std::vector& executed() const { return rg_executed_; } + bool draw_enabled(const std::string& d) const { return !draws_off_.count(d); } + void set_draw_enabled(const std::string& d, bool on) { on ? (void)draws_off_.erase(d) : (void)draws_off_.insert(d); } +private: + std::map shader_info_; + static constexpr VkShaderStageFlags kPushStages = + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT | VK_SHADER_STAGE_COMPUTE_BIT; + struct Globals { int32_t ibl[4]; int32_t pad[4]; }; // ibl = (specular 큐브, irradiance 큐브, LUT 2D, 하늘 큐브) + Globals globals_cpu_{{int32_t(kCubeSpec), int32_t(kCubeIrr), int32_t(kTexLut), int32_t(kCubeSky)}, {0, 0, 0, 0}}; + Buffer globals_; + VkDescriptorSetLayout bl_layout_{}; + VkDescriptorPool bl_pool_{}; + VkDescriptorSet bl_set_{}; + VkPipelineLayout bl_pipe_layout_{}; + uint32_t next_ssbo_ = kSsboFirstFree, next_tex_ = kTexFirstFree; + uint32_t occluder_slot_ = 0, static_lines_slot_ = 0, dyn_lines_slot_ = 0, sphere_slot_ = 0; // 정점 SSBO 슬롯 + std::vector ego_parts_; + VkPipelineLayout shadow_layout_{}; + VkPipeline shadow_pipe_{}; + uint32_t sphere_indices_ = 0; + std::vector spheres_; + Buffer occluder_; + float obj_min_[3] = {0, 0, 0}, obj_max_[3] = {0, 0, 0}; + + Buffer gauss_, splats_, keys_, draw_, ubo_, draw_readback_; + Buffer static_lines_, dyn_lines_; + uint32_t n_static_lines_ = 0, n_dyn_lines_ = 0; + uint32_t n_ = 0, npow2_ = 1, sh_degree_ = 0, obj_start_ = 0; + // 장면 객체 버퍼 (set_scene_objects) + Buffer obj_gauss_, obj_ids_, obj_table_; + uint32_t obj_cap_ = 0, obj_inst_cap_ = 4096, n_obj_ = 0; + uint32_t scene_cap_ = 0; // set_scene_capacity (구간 이어 달리기) + uint64_t gauss_cap_ = 0; // gauss_ 버퍼에 들어가는 가우시안 수 + std::vector asset_gauss_; // 자차 3DGS 에셋 (swap_scene 이 다시 붙인다) + uint32_t obj_gauss_slot_ = 0, obj_ids_slot_ = 0, obj_table_slot_ = 0; + + VkSwapchainKHR swapchain_ = VK_NULL_HANDLE; + VkExtent2D sc_extent_{}; + std::vector sc_images_; + VkFormat sc_format_ = VK_FORMAT_UNDEFINED; + std::vector sc_views_; + std::vector ui_fbs_; + VkRenderPass ui_pass_ = VK_NULL_HANDLE; + bool ui_ = false; + VkSemaphore acquire_sem_ = VK_NULL_HANDLE; + std::vector render_sem_; + bool resize_pending_ = false; +}; + +// ============================================================== 창 모드 +// 자차 치수. 기본은 nuScenes 자차(Renault Zoe 4.1 m, 차 중심이 뒷바퀴 축 1.4 m 앞). 자차 glTF 를 쓰면 +// --ego-length 와 모델의 바퀴 위치로 다시 잡는다 (ego_geom_from_model). +struct EgoDims { + float length = 4.1f; // 렌더 길이 (m) + float center_fwd = 1.39f; // 뒷바퀴 축 → 차 중심 (m) = VehicleGeom 기본값의 center() + vkgs::VehicleGeom geom; // 주행 모델 (축거, 차체 범위) +}; +EgoDims g_ego; + +// 자차 박스 (nusim.recon.init._EGO_BOX 와 같은 크기, 높이 1.6 m) +void ego_box_lines(const EgoPose& e, std::vector& out, std::array color = {1.0f, 0.55f, 0.0f, 1.0f}) { + const vkgs::VehicleGeom& g = g_ego.geom; // 충돌 판정과 같은 차체 + const float x0 = g.rear, x1 = g.front, y0 = -g.half_width, y1 = g.half_width, z0 = 0.0f, z1 = 1.6f; + float c = std::cos(e.yaw), s = std::sin(e.yaw); + auto P = [&](float x, float y, float z) { + return Vec3{e.pos.x + c * x - s * y, e.pos.y + s * x + c * y, e.pos.z + z}; + }; + Vec3 v[8] = {P(x0, y0, z0), P(x1, y0, z0), P(x1, y1, z0), P(x0, y1, z0), + P(x0, y0, z1), P(x1, y0, z1), P(x1, y1, z1), P(x0, y1, z1)}; + int edges[12][2] = {{0, 1}, {1, 2}, {2, 3}, {3, 0}, {4, 5}, {5, 6}, {6, 7}, {7, 4}, {0, 4}, {1, 5}, {2, 6}, {3, 7}}; + for (auto& ed : edges) + for (int k : ed) { + LineVertex lv{{v[k].x, v[k].y, v[k].z}, {color[0], color[1], color[2], color[3]}}; // 기본 주황 (정지선 빨강과 구분) + out.push_back(lv); + } +} + +// 자차 위에서 수직으로 내려다보는 카메라. 화면 위쪽 = 자차 진행 방향. +RenderCam bev_camera(const EgoPose* ego, Vec3 fallback, float fallback_yaw, float height, float clip_above, + int w, int h, float fov_deg = 70.0f) { + Vec3 center = ego ? ego->pos : fallback; + float yaw = ego ? ego->yaw : fallback_yaw; + Vec3 fwd{0, 0, -1}; + Vec3 up_screen{std::cos(yaw), std::sin(yaw), 0}; + Vec3 down_screen = up_screen * -1.0f; + Vec3 right = cross(down_screen, fwd); + RenderCam rc; + rc.w2c = w2c_from(right, down_screen, fwd, center + Vec3{0, 0, height}); + if (clip_above < 1e3f) rc.clip_z = center.z + clip_above; + rc.fx = rc.fy = 0.5f * w / std::tan(0.5f * fov_deg * 3.14159265f / 180.0f); + rc.cx = w * 0.5f; + rc.cy = h * 0.5f; + return rc; +} + + +// 에셋(박스 바닥 중앙 원점)을 자차 위치에. 자차 원점은 뒷바퀴 축이라 차 중심은 g_ego.center_fwd 앞이다. +Mat4 ego_asset_xform(float x, float y, float z, float yaw) { + Mat4 off = Mat4::identity(); + off.at(0, 3) = g_ego.center_fwd; + return ego_matrix(x, y, z, yaw) * off; +} + +// 날씨 후처리에 자차 자세를 알린다: 지면 높이(젖은 노면), 전조등 위치·방향 (차 앞끝, 높이 0.7 m, 3° 아래로) +void set_ego_light(Renderer& r, float x, float y, float z, float yaw) { + float c = std::cos(yaw), s = std::sin(yaw), f = g_ego.geom.front - g_cfg.hl_inset; + r.ground_z = z; + r.ego_pos = {x, y, z}; + r.hl_pos = {x + c * f, y + s * f, z + g_cfg.hl_height}; + r.hl_dir = normalize(Vec3{c, s, g_cfg.hl_pitch}); +} + +// --weather 값: configs/sim.json 의 프리셋 이름 또는 'daylight=0.3,rain=0.6,...' — 이름 뒤에 덮어쓸 수 있다 +// ('night,fog=0.4'). 프리셋 목록은 설정 파일에만 있다. +const WeatherPreset* find_preset(const std::string& name) { + for (const auto& p : g_cfg.presets) + if (p.name == name) return &p; + return nullptr; +} +WeatherParams apply_preset(const WeatherPreset& p) { + WeatherParams w = g_cfg.weather_defaults; + w.enabled = true; + for (const auto& [k, v] : p.values) set_weather_field(w, k, v); + return w; +} +WeatherParams parse_weather(const std::string& spec) { + WeatherParams w = g_cfg.weather_defaults; + w.enabled = true; + std::stringstream ss(spec); + std::string item; + while (std::getline(ss, item, ',')) { + auto eq = item.find('='); + if (eq == std::string::npos) { + const WeatherPreset* p = find_preset(item); + if (!p) throw std::runtime_error("알 수 없는 날씨 프리셋: " + item + " (configs/sim.json weather.presets)"); + int hl = w.headlights; + w = apply_preset(*p); + if (hl != g_cfg.weather_defaults.headlights) w.headlights = hl; + continue; + } + set_weather_field(w, item.substr(0, eq), std::stof(item.substr(eq + 1))); + } + return w; +} + +// glTF 차량(+Y 위, +Z 앞, +X 왼쪽 — glTF 관례) → nuScenes 박스(x 앞, y 왼쪽, z 위). +// 길이를 박스에 맞추는 균일 스케일, 바닥을 지면에. +Mat4 gltf_to_box(const vkgs::GltfModel& m, float box_len) { + float len = m.bmax[2] - m.bmin[2]; + float s = len > 0 ? box_len / len : 1.0f; + float cx = 0.5f * (m.bmin[0] + m.bmax[0]), cz = 0.5f * (m.bmin[2] + m.bmax[2]), y0 = m.bmin[1]; + Mat4 r{}; // 행: 박스 x = glTF z, 박스 y = glTF x, 박스 z = glTF y + r.at(0, 2) = s; r.at(0, 3) = -s * cz; + r.at(1, 0) = s; r.at(1, 3) = -s * cx; + r.at(2, 1) = s; r.at(2, 3) = -s * y0; + r.at(3, 3) = 1; + return r; +} + +struct GltfSet { // 렌더러에 올린 glTF 모델 번호와 CPU 쪽 경계 정보 + const vkgs::GltfModel* other = nullptr; // 다른 차량 + int other_id = -1; + const vkgs::GltfModel* ego = nullptr; // 자차 + int ego_id = -1; +}; + +// 자차 glTF 의 바퀴 위치로 치수를 잡는다: 축거 = 앞뒤 바퀴 중심 거리, 원점 = 뒷바퀴 축. +// 바퀴를 못 찾으면 Zoe 비율(축거 = 길이 × 0.63, 뒤 오버행 = 길이 × 0.24)로 둔다. +EgoDims ego_geom_from_model(const vkgs::GltfModel& m, float length) { + EgoDims d; + d.length = length; + float len = m.bmax[2] - m.bmin[2]; + float s = len > 0 ? length / len : 1.0f; + float zmid = 0.5f * (m.bmin[2] + m.bmax[2]); + float zf = 0, zr = 0; + int nf = 0, nr = 0; + for (const auto& w : m.wheels) { + if (w.front) { zf += w.center[2]; ++nf; } + else { zr += w.center[2]; ++nr; } + } + float rear_axle = nr ? zr / nr : zmid - 0.26f * len; // 모델 Z + float wheelbase = (nf && nr) ? (zf / nf - rear_axle) * s : 0.63f * length; + d.geom.wheelbase = wheelbase; + d.center_fwd = (zmid - rear_axle) * s; + d.geom.rear = (m.bmin[2] - rear_axle) * s; + d.geom.front = (m.bmax[2] - rear_axle) * s; + d.geom.half_width = 0.5f * (m.bmax[0] - m.bmin[0]) * s * 0.9f; // 0.9 = 사이드미러 제외 근사 + return d; +} + +// 자차 glTF 부품 변환 (모델 좌표: +Y 위, +Z 앞, +X 왼쪽). 반환 [0] 차체, [1 + 2k] 바퀴 k, [2 + 2k] 바퀴 k 의 캘리퍼. +// dist 누적 주행 거리 (m, 후진은 줄어든다) → 바퀴 회전각 = dist / 실제 반지름 +// steer 앞바퀴 조향각 (rad, 좌회전 +) — 박스 z 축 회전 = 모델 Y 축 회전 +// pitch 차체 앞 들림 (rad, + = 코가 들린다 — 가속), roll 차체 기울기 (rad, + = 왼쪽이 들린다 — 좌회전) +std::vector ego_parts(const vkgs::GltfModel& m, float box_len, float dist, float steer, float pitch, float roll) { + auto rot = [](int axis, float a) { + Mat4 r = Mat4::identity(); + float c = std::cos(a), s = std::sin(a); + int i = (axis + 1) % 3, j = (axis + 2) % 3; // 오른손: x→(y,z), y→(z,x), z→(x,y) + r.at(i, i) = c; r.at(i, j) = -s; + r.at(j, i) = s; r.at(j, j) = c; + return r; + }; + auto about = [](const float c[3], const Mat4& r) { // c 를 중심으로 r + Mat4 t = Mat4::identity(), ti = Mat4::identity(); + for (int k = 0; k < 3; ++k) { t.at(k, 3) = c[k]; ti.at(k, 3) = -c[k]; } + return t * r * ti; + }; + float len = m.bmax[2] - m.bmin[2]; + float scale = len > 0 ? box_len / len : 1.0f; // 모델 단위 → m + std::vector out; + // 차체: 바닥 중앙을 중심으로. 코 들림 = +Z 가 +Y 로 → X 축 음의 회전. 왼쪽 들림 = +X 가 +Y 로 → Z 축 양의 회전 + float base[3] = {0.5f * (m.bmin[0] + m.bmax[0]), m.bmin[1], 0.5f * (m.bmin[2] + m.bmax[2])}; + out.push_back(about(base, rot(2, roll) * rot(0, -pitch))); + for (const auto& w : m.wheels) { + // 먼저 모델에 구워진 조향각(toe)을 되돌려 축을 X 로 편 다음 굴리고, 앞바퀴면 지금 조향각만큼 꺾는다. + // 앞으로 구르면 바퀴 윗부분이 +Z 로 간다 = X 축 양의 회전 + float spin = w.radius > 0 ? dist / (w.radius * scale) : 0.0f; + Mat4 straight = rot(1, w.toe), turn = w.front ? rot(1, steer) : Mat4::identity(); + out.push_back(about(w.center, turn * rot(0, spin) * straight)); + out.push_back(about(w.center, turn * straight)); // 캘리퍼 + } + return out; +} + +// 접지 그림자 박스: 바닥 중앙 → 씬, x/y 에 길이·폭 +std::vector> vehicle_shadows(const GltfSet& gs, const std::vector* agents, + const EgoPose* ego = nullptr) { + std::vector> out; + auto boxm = [](float x, float y, float z, float yaw, float fwd, float l, float w) { + Mat4 off = Mat4::identity(); + off.at(0, 3) = fwd; + Mat4 sc = Mat4::identity(); + sc.at(0, 0) = l; + sc.at(1, 1) = w; + return ego_matrix(x, y, z, yaw) * off * sc; + }; + // 그림자 폭은 실제로 그려지는 모델 폭에 맞춘다 (박스 폭을 쓰면 넓은 차체 밑에 다 숨는다). + // 0.85 = 사이드미러를 뺀 차체 폭 근사 + auto width_of = [](const vkgs::GltfModel& m, float len) { + float ml = m.bmax[2] - m.bmin[2]; + return (m.bmax[0] - m.bmin[0]) * (ml > 0 ? len / ml : 1.0f) * 0.85f; + }; + if (gs.other && agents) + for (const auto& a : *agents) + out.push_back({gs.other_id, boxm(a.x, a.y, a.z, a.yaw, 0.0f, a.l, width_of(*gs.other, a.l))}); + if (gs.ego && ego) + out.push_back({gs.ego_id, boxm(ego->pos.x, ego->pos.y, ego->pos.z, ego->yaw, g_ego.center_fwd, g_ego.length, + width_of(*gs.ego, g_ego.length))}); + return out; +} + +// 이번 프레임의 glTF 인스턴스: 다른 차량들 (+ 자차) +std::vector> vehicle_draws(const GltfSet& gs, const std::vector* agents, + const EgoPose* ego = nullptr) { + std::vector> out; + if (gs.other && agents) + for (const auto& a : *agents) + out.push_back({gs.other_id, ego_matrix(a.x, a.y, a.z, a.yaw) * gltf_to_box(*gs.other, a.l)}); + if (gs.ego && ego) { + Mat4 off = Mat4::identity(); + off.at(0, 3) = g_ego.center_fwd; // 자차 원점(뒷바퀴 축) → 차 중심 + out.push_back({gs.ego_id, ego_matrix(ego->pos.x, ego->pos.y, ego->pos.z, ego->yaw) * off * + gltf_to_box(*gs.ego, g_ego.length)}); + } + return out; +} + +struct FreeCam { + Vec3 pos; + float yaw = 0, pitch = 0; // yaw: 전역 x 기준 반시계, pitch: 위가 + + Mat4 w2c() const { + Vec3 fwd{std::cos(pitch) * std::cos(yaw), std::cos(pitch) * std::sin(yaw), std::sin(pitch)}; + Vec3 up{0, 0, 1}; + Vec3 right = normalize(cross(fwd, up)); + Vec3 down = cross(fwd, right); + return w2c_from(right, down, fwd, pos); + } + void from_w2c(const Mat4& m) { + pos = cam_position(m); + Vec3 fwd{m.at(2, 0), m.at(2, 1), m.at(2, 2)}; // R 의 3번째 행 = 카메라 z 축의 world 방향 + yaw = std::atan2(fwd.y, fwd.x); + pitch = std::asin(std::clamp(fwd.z, -1.0f, 1.0f)); + } +}; + +struct WindowInit { // 시작 상태 (점검용 스크린샷을 원하는 시점에서 찍으려고) + std::string mode = "follow"; // follow | free | bev + std::string cam = "CAM_FRONT"; + int frame = 0; + float bev_height = 60.0f; + float clip = 2.5f; // BEV 높이 클립 (m, 자차 지면 기준). 1e30 이면 끔 + bool play = false; + bool drive = false; // 직접 운전 모드로 시작 + bool chase = true; // 운전 중 3인칭(차 뒤) 시점 + std::string controls; // 스크립트 제어 파일 (키보드 대신). 줄마다: 지속초 throttle brake steer + std::string record; // 매 프레임 화면을 이 디렉터리에 PNG 로 (데모 영상용) + std::string record_screen; // 매 프레임 창 화면 그대로 (UI · 미리보기 줄 포함) 을 PNG 로 (--record-screen) + bool autopilot = false; // 출발 씬의 기록 궤적을 따라 자동 주행으로 시작 (--autopilot, 창에서 T) + bool show_sensors = false; // 라이다 · 레이더 보기를 켠 채로 시작 (--show-sensors) + bool hide_panels = false; // 패널(날씨 · 센서 등)을 숨기고 시작 — 영상 녹화용 (--hide-panels, 창에서 F1) + float max_speed_kmh = 30.0f; + bool curb = true; // 주행가능영역 경계(연석)에 닿으면 충돌로 멈춘다 (--no-curb 로 끔 — 이탈 경고만) + bool front_cams = false; // 전방 카메라 3대 미리보기로 시작 (--front-cams, 설정 ui.front_cams) + bool bev_pip = false; // BEV 미리보기로 시작 (--bev-pip, 설정 ui.bev_pip) + bool graph_window = false; // 렌더 그래프 창으로 시작 (설정 ui.render_graph, F2) +}; + +// 원래 경로에서 떨어진 거리 → 재구성 품질 등급. 학습 카메라는 그 경로 위에만 있었다. +// 1.5 m 까지는 깨끗하고 3 m 부터 가까운 물체가 깨진다 (docs/roadmap.md 1단계 보강 실측). +enum class Coverage { Good, Degraded, Outside }; +constexpr float kCoverGood = 3.0f, kCoverEdge = 10.0f; +Coverage coverage_of(float log_dev) { + return log_dev <= kCoverGood ? Coverage::Good : (log_dev <= kCoverEdge ? Coverage::Degraded : Coverage::Outside); +} +const char* coverage_name(Coverage c) { + return c == Coverage::Good ? "재구성 양호" : (c == Coverage::Degraded ? "재구성 저하" : "재구성 범위 밖!"); +} + +struct ControlSegment { + float duration, throttle, brake, steer; +}; + +std::vector load_controls(const std::string& path) { + std::ifstream f(path); + if (!f) throw std::runtime_error("제어 파일을 열 수 없음: " + path); + std::vector out; + std::string line; + while (std::getline(f, line)) { + if (line.empty() || line[0] == '#') continue; + std::istringstream ss(line); + ControlSegment c{}; + if (ss >> c.duration >> c.throttle >> c.brake >> c.steer) out.push_back(c); + } + return out; +} + +// 날씨·조도 슬라이더 패널 (F1 로 숨기기). 값은 렌더러의 WeatherParams 를 바로 바꾼다 — 6캠 관측에도 들어간다. +// 전방 카메라 3대 (FRONT_LEFT · FRONT · FRONT_RIGHT) 미리보기 창 — 실제 차량 카메라 배치대로 나란히 +// 렌더 그래프 창 (F2) — 위상 정렬 순서의 패스, 패스별 GPU 시간, 리소스 사용, 켜고 끄기. +// 그래프 자체는 configs/render_graph.json 이 정한다; 여기는 보여 주고 조금 만지는 곳. +void tracy_button(); // 아래 (날씨 패널과 같이 쓴다) + +void render_graph_window(Renderer& r, bool& open) { + ImGui::SetNextWindowSize(ImVec2(420, 0), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("렌더 그래프 (F2)", &open)) { ImGui::End(); return; } + auto& g = r.graph(); + double total = 0; + for (const auto* p : r.executed()) total += p->gpu_ms; // 이번 프레임에 실제로 돈 패스만 (follows 로 건너뛴 것 제외) + ImGui::Text("패스 %zu개, GPU 합계 %.2f ms", g.order().size(), total); + tracy_button(); + if (ImGui::Button("DOT 저장 (render_graph.dot)")) { + std::ofstream("render_graph.dot") << g.to_dot(); + std::cout << " 렌더 그래프 → render_graph.dot (dot -Tsvg render_graph.dot -o rg.svg)\n"; + } + ImGui::Separator(); + int k = 0; + for (int i : g.order()) { + auto& p = g.passes()[i]; + ImGui::PushID(i); + if (p.optional) ImGui::Checkbox("##on", &p.enabled); + else ImGui::TextDisabled(" "); + ImGui::SameLine(); + bool open_node = ImGui::TreeNode("pass", "%d. %-16s [%s] %.2f ms", ++k, p.name.c_str(), p.kind.c_str(), + p.enabled ? p.gpu_ms : 0.0); + if (open_node) { + for (const auto& u : p.uses) + ImGui::BulletText("%s %s : %s", vkgs::usage_writes(u.usage) ? "쓰기" : "읽기", u.resource.c_str(), + vkgs::usage_name(u.usage)); + if (auto* sg = r.shader_graph_for_pass(p.name)) { + ImGui::TextDisabled("셰이더 그래프 %s", sg->name.c_str()); + for (const auto& sp : sg->params) { + float* v = r.graph_param(*sg, sp); + ImGui::SliderFloat(sp.name.c_str(), v, sp.min, sp.max); + } + if (ImGui::Button("기본값")) for (const auto& sp : sg->params) *r.graph_param(*sg, sp) = sp.def; + } + if (p.params.contains("draws")) + for (const auto& d : p.params["draws"]) { + std::string name = d.get(); + bool on = r.draw_enabled(name); + if (ImGui::Checkbox(name.c_str(), &on)) r.set_draw_enabled(name, on); + } + ImGui::TreePop(); + } + ImGui::PopID(); + } + ImGui::End(); +} + +// 전방 카메라 3대 (V) · BEV (N) 미리보기: 화면 위쪽 한 줄에 딱 맞게 — 켜진 것만 같은 크기 칸으로 나란히 둔다 +// (FRONT_LEFT · FRONT · FRONT_RIGHT · BEV). 칸 높이는 화면 높이의 ui.pip_strip_height_frac 이하, 너비는 왼쪽 위 +// 패널(날씨 · 조도)이 떠 있으면 그 오른쪽부터 창 오른쪽 끝까지. 창 테두리·입력 없음 — 그 위에서도 마우스 시점 조작이 된다 +float g_panel_right = 0; // 이번 프레임 왼쪽 위 패널의 오른쪽 끝 (weather_panel 이 채운다, 없으면 0) +void pip_strip(Renderer& r, bool front, bool bev, float bev_height) { + int ids[4], n = 0; + if (front) for (int k = 0; k < 3; ++k) ids[n++] = k; + if (bev) ids[n++] = 3; + if (!n) return; + const float m = 6, gap = 4; + ImVec2 ds = ImGui::GetIO().DisplaySize, sz = r.pip_size(); + float x0 = g_panel_right > 0 ? g_panel_right + m : m, x1 = ds.x - m; + float aspect = sz.x / std::max(sz.y, 1.0f); + float tw = std::min((x1 - x0 - gap * (n - 1)) / n, ds.y * g_cfg.pip_strip_height_frac * aspect); + tw = std::max(tw, 32.0f); + float th = tw / aspect, left = x0 + std::max(0.0f, (x1 - x0 - (tw * n + gap * (n - 1))) * 0.5f); + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ds); + ImGui::Begin("##pip_strip", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoBackground | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNav); + ImDrawList* dl = ImGui::GetWindowDrawList(); + static const char* labels[4] = {"FRONT_LEFT", "FRONT", "FRONT_RIGHT", "BEV"}; + char bl[48]; + std::snprintf(bl, sizeof bl, "BEV 위에서 %.0f m", bev_height); + for (int i = 0; i < n; ++i) { + ImVec2 p0(left + i * (tw + gap), m), p1(p0.x + tw, p0.y + th); + dl->AddRectFilled(ImVec2(p0.x - 1, p0.y - 1), ImVec2(p1.x + 1, p1.y + 1), IM_COL32(0, 0, 0, 200)); + dl->AddImage(r.pip_texture(ids[i]), p0, p1); + const char* t = ids[i] == 3 ? bl : labels[ids[i]]; + ImVec2 ts = ImGui::CalcTextSize(t); + dl->AddRectFilled(p0, ImVec2(p0.x + ts.x + 8, p0.y + ts.y + 4), IM_COL32(0, 0, 0, 150)); + dl->AddText(ImVec2(p0.x + 4, p0.y + 2), IM_COL32(255, 255, 255, 230), t); + } + ImGui::End(); +} + +// Tracy 프로파일러 GUI 를 따로 띄워 이 시뮬레이터에 바로 붙인다 (-a 127.0.0.1). 이미 떠 있으면 다시 띄우지 않는다. +// 뷰어가 Tracy 없이(VKGS_TRACY=OFF) 빌드됐거나 GUI 가 아직 빌드되지 않았으면 버튼을 끄고 이유를 보인다. +void tracy_button() { + static pid_t pid = 0; + static std::string status; + if (pid > 0 && waitpid(pid, nullptr, WNOHANG) != 0) pid = 0; // 창을 닫았다 +#ifdef TRACY_ENABLE + bool built = std::filesystem::exists(g_cfg.tracy_profiler); +#else + bool built = false; +#endif + ImGui::BeginDisabled(!built || pid > 0); + if (ImGui::Button(pid > 0 ? "Tracy 프로파일러 실행 중" : "Tracy 프로파일러 실행")) { + std::string exe = g_cfg.tracy_profiler; + char a0[] = "tracy-profiler", a1[] = "-a", a2[] = "127.0.0.1"; + char* argv[] = {a0, a1, a2, nullptr}; + if (posix_spawn(&pid, exe.c_str(), nullptr, nullptr, argv, ::environ) != 0) { // 환경 그대로 (DISPLAY 등) + pid = 0; + status = "실행 실패: " + exe; + } else { + status = "붙는 중 — Tracy 창에서 CPU·GPU 타임라인"; + std::cout << " Tracy 프로파일러 실행 (pid " << pid << ")\n"; + } + } + ImGui::EndDisabled(); + if (!built) { +#ifdef TRACY_ENABLE + ImGui::TextDisabled("(GUI 없음: bash scripts/build_tracy.sh)"); +#else + ImGui::TextDisabled("(Tracy 없이 빌드됨: cmake -DVKGS_TRACY=ON)"); +#endif + } else if (!status.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("%s", status.c_str()); + } +} + +void weather_panel(WeatherParams& w, bool& front_cams, bool& bev_pip, int& camera_mode) { + ImGui::SetNextWindowPos(ImVec2(12, 12), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(330, 0), ImGuiCond_FirstUseEver); + ImGui::Begin("날씨 · 조도 (F1 숨기기)"); + if (ImGui::GetWindowPos().y < 40) // 위쪽에 붙어 있으면 미리보기 줄이 그 오른쪽부터 (pip_strip) + g_panel_right = ImGui::GetWindowPos().x + ImGui::GetWindowSize().x; + ImGui::Checkbox("전방 카메라 3대 보기 (V)", &front_cams); + ImGui::SameLine(); + ImGui::Checkbox("BEV 보기 (N)", &bev_pip); + // 카메라 모드 — 화면 · 전방 3대 · BEV 미리보기 · 녹화에 같이 (6캠 관측 저장 O 는 늘 RGB) + static const char* modes[4] = {"RGB", "깊이", "시맨틱", "흑백"}; + ImGui::TextUnformatted("카메라 (F3)"); + for (int k = 0; k < 4; ++k) { + ImGui::SameLine(); + ImGui::RadioButton(modes[k], &camera_mode, k); + } + tracy_button(); + ImGui::Separator(); + ImGui::Checkbox("합성 날씨 켜기", &w.enabled); + for (size_t k = 0; k < g_cfg.presets.size(); ++k) { // configs/sim.json 의 프리셋 + if (k % 4) ImGui::SameLine(); + if (ImGui::Button(g_cfg.presets[k].label.c_str(), ImVec2(70, 0))) { + int hl = w.headlights; + w = apply_preset(g_cfg.presets[k]); + w.headlights = hl; + } + } + ImGui::Separator(); + ImGui::BeginDisabled(!w.enabled); + ImGui::SliderFloat("밝기 (밤↔낮)", &w.daylight, 0.0f, 1.0f, "%.2f"); + ImGui::SliderFloat("구름", &w.cloud, 0.0f, 1.0f, "%.2f"); + ImGui::SliderFloat("비", &w.rain, 0.0f, 1.0f, "%.2f"); + ImGui::SliderFloat("안개", &w.fog, 0.0f, 1.0f, "%.2f"); + ImGui::SliderFloat("노면 젖음", &w.wet, 0.0f, 1.0f, "%.2f"); + ImGui::SliderFloat("눈", &w.snow, 0.0f, 1.0f, "%.2f"); + ImGui::SliderFloat("쌓인 눈", &w.snow_cover, 0.0f, 1.0f, "%.2f"); + ImGui::TextUnformatted("전조등"); + ImGui::SameLine(); + ImGui::RadioButton("자동", &w.headlights, -1); + ImGui::SameLine(); + ImGui::RadioButton("끔", &w.headlights, 0); + ImGui::SameLine(); + ImGui::RadioButton("켬", &w.headlights, 1); + ImGui::EndDisabled(); + ImGui::End(); +} + +// ---- 레이캐스트 센서 (sensors.hpp): 라이다·레이더를 카메라처럼 자차에 붙인다 ---- +static vkgs::VoxelWorld g_voxels; // --voxels (nusim/sim/voxels.py) +struct SensorUi { + bool lidar = false, radar = false; // 보기 (패널 체크박스) + std::vector> lidar_pts; + std::vector> radar_pts; + double ms = 0; + uint32_t seed = 1; + void scan(const vkgs::EgoState& e, const std::vector& boxes, bool all) { + auto t0 = std::chrono::steady_clock::now(); + ++seed; + lidar_pts.assign(g_cfg.lidars.size(), {}); + radar_pts.assign(g_cfg.radars.size(), {}); + if (lidar || all) + for (size_t i = 0; i < g_cfg.lidars.size(); ++i) lidar_pts[i] = vkgs::lidar_scan(g_voxels, boxes, g_cfg.lidars[i], e, seed); + if (radar || all) + for (size_t i = 0; i < g_cfg.radars.size(); ++i) radar_pts[i] = vkgs::radar_scan(g_voxels, boxes, g_cfg.radars[i], e, seed); + ms = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + } + // 화면 표시: 라이다 = 높이로 색칠한 짧은 세로선, 레이더 = 십자 (빨강 다가옴 · 파랑 멀어짐 · 노랑 거의 정지) + void draw(std::vector& out, float ground_z) const { + if (lidar) + for (const auto& pts : lidar_pts) + for (const auto& p : pts) { + float h = std::clamp((p.p[2] - ground_z) / 6.0f, 0.0f, 1.0f); // 0 m 파랑 → 3 m 초록 → 6 m 빨강 + std::array c = {std::clamp(2 * h - 1, 0.0f, 1.0f), 1 - std::abs(2 * h - 1), std::clamp(1 - 2 * h, 0.0f, 1.0f), 1}; + out.push_back({{p.p[0], p.p[1], p.p[2]}, {c[0], c[1], c[2], c[3]}}); + out.push_back({{p.p[0], p.p[1], p.p[2] + 0.06f}, {c[0], c[1], c[2], c[3]}}); + } + if (radar) + for (const auto& pts : radar_pts) + for (const auto& p : pts) { + std::array c = p.vr < -0.5f ? std::array{1, 0.15f, 0.1f, 1} + : p.vr > 0.5f ? std::array{0.2f, 0.4f, 1, 1} + : std::array{1, 0.9f, 0.1f, 1}; + const float a = 0.35f; + for (int k = 0; k < 3; ++k) { + float d[3] = {0, 0, 0}; + d[k] = a; + out.push_back({{p.p[0] - d[0], p.p[1] - d[1], p.p[2] - d[2]}, {c[0], c[1], c[2], c[3]}}); + out.push_back({{p.p[0] + d[0], p.p[1] + d[1], p.p[2] + d[2]}, {c[0], c[1], c[2], c[3]}}); + } + } + } + void save(const std::string& dir) const { + for (size_t i = 0; i < lidar_pts.size(); ++i) + vkgs::write_lidar_bin(dir + "/" + g_cfg.lidars[i].mount.name + ".bin", lidar_pts[i], g_cfg.lidars[i].range); + for (size_t i = 0; i < radar_pts.size(); ++i) + vkgs::write_radar_txt(dir + "/" + g_cfg.radars[i].mount.name + ".txt", radar_pts[i]); + } +}; + +void sensor_panel(SensorUi& s) { + ImGui::SetNextWindowPos(ImVec2(12, 420), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(330, 0), ImGuiCond_FirstUseEver); + ImGui::Begin("센서 (라이다 · 레이더)"); + if (g_voxels.empty()) { + ImGui::TextUnformatted("복셀 월드(--voxels)가 없어 끔"); + ImGui::End(); + return; + } + ImGui::Checkbox("라이다 보기", &s.lidar); + ImGui::SameLine(); + ImGui::Checkbox("레이더 보기", &s.radar); + size_t nl = 0, nr = 0; + for (const auto& p : s.lidar_pts) nl += p.size(); + for (const auto& p : s.radar_pts) nr += p.size(); + ImGui::Text("라이다 %zu대 %zu점 · 레이더 %zu대 %zu점 · %.1f ms", g_cfg.lidars.size(), nl, g_cfg.radars.size(), nr, s.ms); + ImGui::TextUnformatted("레이더 색: 빨강 다가옴 · 파랑 멀어짐 · 노랑 정지"); + ImGui::TextUnformatted("O (6캠 관측 저장) 에 센서 데이터도 같이 저장"); + ImGui::End(); +} + +// 구간 모델 이어 달리기 (segment_map.hpp): 자차 위치에서 가장 가까운 구간을 그리고, 이웃 구간은 백그라운드 스레드가 +// 미리 읽어 둔다 (ply 0.5 GB 읽기 1~2초). 경계를 넘을 때 읽혀 있으면 GPU 버퍼만 바꾼다 — 아직이면 읽힐 때까지 지금 구간을 그린다. +struct SegmentStreamer { + vkgs::SegmentMap map; + size_t active = 0; + std::map>> cache; // 지금 · 가까운 구간만 둔다 + + static std::shared_ptr load(const vkgs::MapSegment& m) { + auto s = std::make_shared(vkgs::load_ply(m.ply)); + float d[3] = {float(m.offset[0]), float(m.offset[1]), float(m.offset[2])}; // 그 구간 원점 → 공통 원점 + for (auto& g : s->gaussians) + for (int a = 0; a < 3; ++a) g.pos_opacity[a] += d[a]; + return s; + } + // 센서용 복셀 월드: 지역 맵은 씬마다 하나 (segments.txt W 줄) — 그리는 구간의 씬 것을 g_voxels 로 둔다 + int world_active = -1; + std::map>> worlds; + static std::shared_ptr load_world(const vkgs::MapWorld& m) { + auto w = std::make_shared(); + if (m.path == "-") return w; // 이 씬은 아직 복셀 월드가 없다 (nusim render map --sensors) + try { + w->load(m.path); + w->shift(m.offset); + } catch (const std::exception& e) { + std::printf(" 센서 복셀 월드 못 읽음: %s\n", e.what()); + w = std::make_shared(); + } + return w; + } + void prefetch_world(int k) { + if (k < 0 || worlds.count(k)) return; + worlds[k] = std::async(std::launch::async, load_world, map.worlds[size_t(k)]).share(); + } + void update_world(const std::vector& near, bool block) { + if (map.worlds.empty()) return; + int want = map.segs[active].world; + prefetch_world(want); + for (size_t i : near) prefetch_world(map.segs[i].world); + if (want >= 0 && want != world_active && (block || ready(worlds[want]))) { + g_voxels = *worlds[want].get(); + world_active = want; + std::printf("센서 복셀 월드 → %s\n", g_voxels.empty() ? "없음 (이 씬은 센서 꺼짐)" : map.worlds[size_t(want)].path.c_str()); + std::fflush(stdout); + } + for (auto it = worlds.begin(); it != worlds.end();) { + bool k = it->first == want || it->first == world_active || + std::any_of(near.begin(), near.end(), [&](size_t i) { return map.segs[i].world == it->first; }); + if (!k && ready(it->second)) it = worlds.erase(it); + else ++it; + } + } + void prefetch(size_t i) { + if (i >= map.segs.size() || cache.count(i)) return; + cache[i] = std::async(std::launch::async, load, map.segs[i]).share(); + } + template + static bool ready(const std::shared_future& f) { + return f.wait_for(std::chrono::seconds(0)) == std::future_status::ready; + } + // 매 프레임: 그릴 구간을 고르고, 바뀌었고 읽혀 있으면 버퍼를 바꾼다. 바꿨으면 true + // block: 읽힐 때까지 기다린다 (오프스크린 — 프레임마다 맞는 구간으로 그려야 한다) + bool update(Renderer& r, float x, float y, bool block = false) { + size_t want = map.pick(x, y, active); + prefetch(want); + std::vector near = map.nearest_k(x, y, 3); // 가까운 구간 3개를 미리 (여러 씬 지역 맵도) + for (size_t i : near) prefetch(i); + bool swapped = false; + if (want != active && (block || ready(cache[want]))) { + auto t0 = std::chrono::steady_clock::now(); + r.swap_scene(*cache[want].get()); + std::printf("구간 %d → %d (%.2f초)\n", map.segs[active].k, map.segs[want].k, + std::chrono::duration(std::chrono::steady_clock::now() - t0).count()); + std::fflush(stdout); + active = want; + swapped = true; + } + update_world(near, block); + std::vector keep = map.nearest_k(x, y, 4); // 멀어진 구간은 메모리에서 뺀다 (읽는 중이면 끝날 때까지 둔다) + keep.push_back(active); + keep.push_back(want); + for (auto it = cache.begin(); it != cache.end();) { + bool k = std::find(keep.begin(), keep.end(), it->first) != keep.end(); + if (!k && ready(it->second)) it = cache.erase(it); + else ++it; + } + return swapped; + } +}; + +int run_window(Renderer& r, const Rig& rig, const std::string& shot_dir, int autoshot, const WindowInit& init, + const vkgs::Drivable& drivable, const vkgs::ObstacleGrid& obst, const GltfSet& gltf = {}, + vkgs::ObjectWorld* world = nullptr, SegmentStreamer* segs = nullptr) { + enum class Mode { Follow, Free, Bev, Drive } mode = rig.views.empty() ? Mode::Free : Mode::Follow; + if (init.mode == "bev") mode = Mode::Bev; + if (init.mode == "free") mode = Mode::Free; + int frame = std::clamp(init.frame, 0, std::max(rig.n_frames - 1, 0)), cam_idx = 0; + bool playing = init.play; + FreeCam fc; + float bev_height = init.bev_height; + // BEV 높이 클립: 자차 지면 기준 이 높이 위는 안 그린다. 학습 카메라가 1.5 m 높이에서 수평으로만 + // 봤기 때문에, 위에서 보면 나무 캐노피와 바늘 모양 가우시안이 지면을 덮는다. + float clip_above_ground = init.clip; + // 자유/BEV 시점의 내부 파라미터: 수평 시야 70° + float f_free = 0.5f * r.width() / std::tan(35.0f * 3.14159265f / 180.0f); + auto t_prev = std::chrono::steady_clock::now(); + double play_clock = 0; + double mx = 0, my = 0; + bool dragging = false; + int n_frame_drawn = 0, shot_count = 0; + std::map was_down; + r.init_ui(); + bool show_ui = !init.hide_panels; + bool front_cams = init.front_cams; // 전방 카메라 3대 미리보기 (패널 체크박스 또는 V) + bool bev_pip = init.bev_pip; // BEV 미리보기 (패널 체크박스 또는 N) + bool graph_window = init.graph_window; + int pip_turn = 0; // 프레임마다 한 대씩 돌아가며 다시 그린다 (3대를 다 그리면 프레임이 4배 느려진다) + auto pressed = [&](int key) { // 눌린 순간만 참 (UI 에 글자를 치는 중이면 무시) + bool down = glfwGetKey(r.window(), key) == GLFW_PRESS && + !(r.ui_ready() && ImGui::GetIO().WantCaptureKeyboard); + bool edge = down && !was_down[key]; + was_down[key] = down; + return edge; + }; + const EgoPose* ego_at = nullptr; + auto ego_for = [&](int fr) -> const EgoPose* { + for (const auto& e : rig.ego) + if (e.frame == fr) return &e; + return nullptr; + }; + if (!rig.views.empty()) { + auto it = std::find(rig.cam_names.begin(), rig.cam_names.end(), init.cam); + cam_idx = it == rig.cam_names.end() ? 0 : int(it - rig.cam_names.begin()); + if (auto v = rig.find(frame, rig.cam_names[cam_idx])) fc.from_w2c(v->w2c); + } + + // ---------------------------------------------------------- 직접 운전 (closed-loop) + vkgs::Vehicle car; + bool chase = init.chase; + int drive_start_frame = frame, obs_count = 0; + double sim_time = 0, trail_clock = 0; + int64_t drive_t0 = 0; // 운전을 시작한 기록 시각 (µs) — 다른 차량은 기록 궤적을 이 시각부터 재생 + std::vector obj_table; + bool was_on_road = true; + vkgs::Debounce road; + float steer_cmd = 0; // 키보드 조향 — 누르는 동안 서서히 돌고, 떼면 가운데로 돌아온다 + // 3인칭 시점의 방위. 정뒤는 에셋에서 가장 덜 관측된 면(뒷면)이라 살짝 비껴 왼쪽 뒤에서 본다. + // 너무 돌리면 카메라가 학습 궤적 밖으로 나가 배경이 깨진다 (25° = 옆으로 3.2 m 에서 실측) — 10° ≈ 1.3 m. + float chase_yaw = g_cfg.chase_yaw_offset_deg * 3.14159265f / 180.0f; + if (const char* e = std::getenv("VKGS_CHASE_YAW")) chase_yaw = std::stof(e) * 3.14159265f / 180.0f; // 점검용 (옆에서 보기 등) + float chase_zoom = 1; + bool mid_prev = false; // 휠 / 가운데 버튼 세로 드래그 = 차에서 멀어지기·가까워지기 (차 뒤 시점) + float chase_pitch = 0; // 운전 중 세로 드래그 = 시선 올리기/내리기 (+ = 위, 하늘). 차는 카메라 앞에 그대로 + // 차가 '움직여 보이게' 하는 상태. 3인칭 카메라를 차에 못 박아 두면 차는 화면 한 자리에 고정되고 배경만 + // 흘러가 '차 그림을 붙여 놓은 것' 처럼 보인다 (사용자 지적). 그래서 + // - 바퀴: 주행 거리만큼 구르고 앞바퀴는 조향각만큼 꺾인다 + // - 차체: 가속·제동에 앞뒤로, 선회에 바깥쪽으로 기운다 (감쇠 스프링 = 서스펜션) + // - 카메라: 차를 스프링처럼 늦게 따라간다 — 선회하면 차가 화면에서 돌아가고, 가속하면 멀어진다 + float wheel_dist = 0; + float body_pitch = 0, body_pitch_v = 0, body_roll = 0, body_roll_v = 0; + float cam_yaw = 0, cam_back = 7.5f; + bool cam_init = false; + // 충돌: 차체가 장애물(건물·기둥·벽)이나 연석(주행가능영역 경계)에 닿으면 그 직전 자세로 되돌리고 멈춘다. + // 처음부터 닿아 있던 자세(출발점이 지도 잡음 위 등)에서는 빠져나갈 수 있게 막지 않는다. + vkgs::Contact touching = vkgs::Contact::None; // 화면·집계용 (0.5 s 래치) + vkgs::ContactInfo cur_contact; // 지금 자세에서 닿은 샘플 수 + const vkgs::ContactParams contact_params{init.curb, g_cfg.curb_tol, g_cfg.obst_tol, g_cfg.graze_samples}; + int n_collisions = 0; + float free_time = 0; // 막힘에서 풀린 뒤 지난 시간 — 벽을 따라 미끄러지며 같은 충돌을 여러 번 세지 않게 + auto contact_name = [](vkgs::Contact c) { + return c == vkgs::Contact::Vehicle ? "다른 차량" : c == vkgs::Contact::Obstacle ? "건물·구조물" : "연석(도로 경계)"; + }; + // 다른 차량(장면 객체)의 바닥 사각형 — 장애물 지도는 차량을 지운 배경이라 따로 본다. 지운 객체는 빠진다 + std::vector others; + auto update_others = [&](int64_t ts) { + others.clear(); + if (!world) return; + for (size_t k = 0; k < world->objects.size(); ++k) { + vkgs::ObjKeyframe p; + if (!world->box_at(k, ts, p)) continue; + const auto& o = world->objects[k]; + double qw = p.q[0], qx = p.q[1], qy = p.q[2], qz = p.q[3]; + float yaw = float(std::atan2(2 * (qw * qz + qx * qy), 1 - 2 * (qy * qy + qz * qz))); + others.push_back({float(p.t[0] - world->origin[0]), float(p.t[1] - world->origin[1]), yaw, + 0.5f * o.size[1], 0.5f * o.size[0]}); // size = w, l, h + } + }; + Coverage last_cover = Coverage::Good; + std::vector> trail; + std::vector> log_path; + for (const auto& e : rig.ego) log_path.push_back({e.pos.x, e.pos.y}); + bool autopilot = init.autopilot; // 기록 궤적 따라가기 (T) + size_t autopilot_idx = 0; + std::ofstream drive_log; + auto enter_drive = [&](int fr) { + const EgoPose* e = ego_for(fr); + if (!e) return; + if (rig.rigs.empty()) { + std::cout << " 카메라 리그(R 줄)가 없어 운전할 수 없음 — nusim render export 를 다시 실행하세요\n"; + return; + } + car = {}; + car.geom = g_ego.geom; + car.max_speed = init.max_speed_kmh / 3.6f; + car.x = e->pos.x; car.y = e->pos.y; car.z = e->pos.z; car.yaw = e->yaw; + drive_t0 = e->ts; + car.v = 0; // 정지 상태로 시작 — 예전엔 로그의 그 순간 속도로 시작해 창을 열면 차가 저절로 달렸다 (사용자 지적, 2026-10-09) + drive_start_frame = fr; + sim_time = 0; + trail.clear(); + was_on_road = true; + road = {}; + steer_cmd = 0; + last_cover = Coverage::Good; + body_pitch = body_pitch_v = body_roll = body_roll_v = 0; + cam_init = false; + chase = init.chase; // 운전을 (다시) 시작하면 늘 차 뒤 시점 — 1~6/C 로 바꿔 둔 시점이 남아 '탑승 시점' 으로 시작하지 않게 + touching = vkgs::Contact::None; + n_collisions = 0; + update_others(drive_t0); + cur_contact = vkgs::vehicle_contact(car, obst, drivable, init.curb, g_cfg.curb_tol, g_cfg.obst_tol, &others); + if (cur_contact.total()) + std::printf(" 출발 자세가 이미 %s 에 닿아 있음 (%d점) — 빠져나가는 방향으로만 움직일 수 있다\n", + contact_name(cur_contact.kind), cur_contact.total()); + mode = Mode::Drive; + if (!drive_log.is_open()) { + fs::create_directories(shot_dir); + drive_log.open(shot_dir + "/drive_log.csv"); + drive_log << "t,x,y,yaw,v,steer_deg,throttle,brake,on_road,on_road_raw,log_dev_m,coverage,contact\n"; + } + std::printf(" 운전 시작 — 프레임 %d, %.1f km/h\n", fr, car.v * 3.6f); + }; + // 카메라 cam 의 운전 중 view + auto drive_cam = [&](const CamRig& cr) { + RenderCam rc; + rc.w2c = rigid_inverse(ego_matrix(car.x, car.y, car.z, car.yaw) * cr.cam2ego); + float sx = float(r.width()) / cr.width, sy = float(r.height()) / cr.height; + rc.fx = cr.fx * sx; rc.fy = cr.fy * sy; rc.cx = cr.cx * sx; rc.cy = cr.cy * sy; + rc.sensor = true; + return rc; + }; + // 스크립트 제어: 고정 50 ms 스텝으로 재현 가능하게 돈다. 구간이 끝날 때마다 6캠 관측 + 화면을 저장하고, + // 전부 끝나면 종료한다. VLA 모델이 들어올 자리와 같은 인터페이스(제어 → 다음 관측)다. + std::vector script; + size_t seg = 0; + double seg_time = 0; + if (!init.controls.empty()) { + script = load_controls(init.controls); + std::printf(" 스크립트 제어 %zu구간\n", script.size()); + } + bool script_save = false; + if (init.drive || !script.empty()) enter_drive(frame); + + std::cout << "\n조작: WASD/QE 이동 · 마우스 드래그 시점 · Shift 빠르게\n" + " 1~6 카메라 추종 · Space 궤적 재생 · ←/→ 프레임 · B 위에서 보기(BEV)\n" + " M 맵 표시 · H BEV 높이 클립 켜기/끄기 · [ ] 클립 높이 · P 스크린샷 · Esc 종료\n" + "운전: G 운전 시작/끝 · ↑/W 가속 · ↓/S 브레이크(정지 후 후진) · ←→/AD 조향 · Space 핸드브레이크\n" + " C 차 뒤 시점 ↔ 카메라 시점 · 차 뒤 시점에서 마우스 드래그 = 가로: 차 주위 회전, 세로: 시선 위아래 (하늘 보기)\n" + " 휠 또는 가운데 버튼 세로 드래그 = 차에서 멀어지기 · 가까워지기\n" + " 1~6 카메라 · R 출발점으로 · O 6캠 관측 저장 · V 전방 카메라 3대 · N BEV 미리보기 · K 장면 객체 편집\n" + " T 자동 주행 (기록 궤적 따라가기) · F3 카메라 모드 (RGB · 깊이 · 시맨틱 · 흑백)\n\n"; + + // ---- 장면 객체 편집 (K: 객체 창, 편집 모드에서 왼쪽 클릭 선택·드래그 이동, 휠 회전, Delete 삭제, 추가) ---- + int64_t obj_ts = 0; + bool obj_window = false, obj_edit = false, obj_drag = false, obj_lb_prev = false; + int obj_sel = -1, obj_place = -1, obj_lib = 0; + double obj_grab[2] = {0, 0}; + RenderCam last_rc; + bool have_rc = false; + SensorUi sensors; + sensors.lidar = sensors.radar = init.show_sensors; + auto mouse_ray = [&](double px, double py, double o[3], double d[3]) { // 직전 프레임 카메라로 마우스 광선 (씬 로컬) + if (!have_rc) return false; + int ww = 1, wh = 1; + glfwGetWindowSize(r.window(), &ww, &wh); + // 화면은 비율을 유지해 가운데에 맞춰 그린다 (record_blit_to_swapchain) — 창을 늘리면 여백이 생긴다 + double s = std::min(double(std::max(ww, 1)) / r.width(), double(std::max(wh, 1)) / r.height()); + double u = (px - 0.5 * (ww - r.width() * s)) / s, v = (py - 0.5 * (wh - r.height() * s)) / s; + Mat4 c2w = rigid_inverse(last_rc.w2c); + double dc[3] = {(u - last_rc.cx) / last_rc.fx, (v - last_rc.cy) / last_rc.fy, 1.0}, n = 0; + for (int k = 0; k < 3; ++k) { + d[k] = c2w.at(k, 0) * dc[0] + c2w.at(k, 1) * dc[1] + c2w.at(k, 2) * dc[2]; + o[k] = c2w.at(k, 3); + n += d[k] * d[k]; + } + for (int k = 0; k < 3; ++k) d[k] /= std::sqrt(n); + return true; + }; + auto ground_hit = [&](const double o[3], const double d[3], double out[2]) { // 광선 ∩ 자차 지면 (씬 로컬) + double gz = mode == Mode::Drive ? car.z : (ego_at ? ego_at->pos.z : 0.0); + if (d[2] > -1e-4) return false; + double t = (gz - o[2]) / d[2]; + out[0] = o[0] + t * d[0]; + out[1] = o[1] + t * d[1]; + return true; + }; + auto rebuild_objects = [&]() { + std::vector g; + std::vector id; + world->build_gpu(g, id); + r.set_scene_objects(g, id); + }; + auto own_pose = [&](int k) { // 사용자가 만지면 그때부터 궤적 대신 고정 자세 + auto& o = world->objects[k]; + if (o.user_posed) return true; + vkgs::ObjKeyframe p; + if (!world->pose_at(o, obj_ts, p)) return false; + o.pose = p; + o.user_posed = true; + return true; + }; + + while (!glfwWindowShouldClose(r.window())) { + glfwPollEvents(); + auto now = std::chrono::steady_clock::now(); + float dt = std::chrono::duration(now - t_prev).count(); + t_prev = now; + GLFWwindow* w = r.window(); + if (glfwGetKey(w, GLFW_KEY_ESCAPE) == GLFW_PRESS) break; + + // 스크립트 제어 중에는 키보드를 무시한다 — 창에 포커스가 있을 때 다른 곳에 치던 글자가 + // 들어와 R(출발점으로) 등이 눌리는 일이 있었다 (실측: 스크립트 기록이 중간에 두 번 리셋됨) + bool scripted = seg < script.size(); + // --- 운전 모드 전환 + if (pressed(GLFW_KEY_G) && !scripted) { + if (mode == Mode::Drive) mode = Mode::Follow; + else enter_drive(frame); + } + bool driving = mode == Mode::Drive; + bool focused = glfwGetWindowAttrib(w, GLFW_FOCUSED) != 0; + + // --- 시간축 (운전 중에는 방향키·Space 가 운전에 쓰인다) + if (!driving) { + if (pressed(GLFW_KEY_SPACE)) playing = !playing; + if (pressed(GLFW_KEY_RIGHT)) frame = std::min(frame + 1, std::max(rig.n_frames - 1, 0)); + if (pressed(GLFW_KEY_LEFT)) frame = std::max(frame - 1, 0); + } else { + playing = false; + } + if (playing && rig.n_frames > 1) { + // 실제 타임스탬프 속도로 재생 (CAM_FRONT 기준 약 12 Hz) + play_clock += dt; + const CamView* cur = rig.find(frame, rig.cam_names[cam_idx]); + const CamView* nxt = rig.find(frame + 1, rig.cam_names[cam_idx]); + if (cur && nxt && play_clock >= (nxt->ts - cur->ts) * 1e-6) { + play_clock = 0; + frame = (frame + 1) % rig.n_frames; + } else if (!nxt) { + frame = 0; + } + } + ego_at = ego_for(frame); + + // --- 시점 모드 + for (int i = 0; i < 6 && i < int(rig.cam_names.size()); ++i) + if (pressed(GLFW_KEY_1 + i)) { + if (driving) chase = false; + else mode = Mode::Follow; + cam_idx = i; + } + if (pressed(GLFW_KEY_C) && driving && !scripted) chase = !chase; + if (pressed(GLFW_KEY_R) && driving && !scripted) enter_drive(drive_start_frame), autopilot_idx = 0; + bool save_obs = pressed(GLFW_KEY_O) && driving && !scripted; + if (pressed(GLFW_KEY_B) && !driving) mode = (mode == Mode::Bev) ? Mode::Follow : Mode::Bev; + if (pressed(GLFW_KEY_M)) r.show_lines = !r.show_lines; + if (pressed(GLFW_KEY_H)) clip_above_ground = clip_above_ground < 1e3f ? 1e30f : 2.5f; + if (pressed(GLFW_KEY_RIGHT_BRACKET) && clip_above_ground < 1e3f) clip_above_ground += 0.5f; + if (pressed(GLFW_KEY_LEFT_BRACKET) && clip_above_ground < 1e3f) clip_above_ground = std::max(0.0f, clip_above_ground - 0.5f); + bool shot = pressed(GLFW_KEY_P); + + // 마우스 드래그 → 자유 시점으로 전환 + double cx, cy; + glfwGetCursorPos(w, &cx, &cy); + // 패널 위에서 누른 마우스는 카메라를 돌리지 않는다 + // 객체 편집 모드에서는 왼쪽 버튼이 선택·이동이라 카메라는 오른쪽 드래그로 돌린다 + int cam_btn = (world && obj_edit) ? GLFW_MOUSE_BUTTON_RIGHT : GLFW_MOUSE_BUTTON_LEFT; + bool ldown = glfwGetMouseButton(w, cam_btn) == GLFW_PRESS && + !(r.ui_ready() && ImGui::GetIO().WantCaptureMouse && !dragging); + if (world && obj_edit && r.ui_ready()) { + bool lb = glfwGetMouseButton(w, GLFW_MOUSE_BUTTON_LEFT) == GLFW_PRESS && !ImGui::GetIO().WantCaptureMouse; + double o[3], d[3], gp[2]; + const auto& org = world->origin; + if (lb && !obj_lb_prev && mouse_ray(cx, cy, o, d)) { + if (obj_place >= 0) { // 추가: 클릭한 지면에, 카메라가 보는 방향으로 + if (ground_hit(o, d, gp)) { + const vkgs::SceneObject* src = nullptr; + for (const auto& so : world->objects) + if (so.asset == obj_place) { src = &so; break; } + std::array sz = src ? src->size : std::array{1.8f, 4.5f, 1.5f}; + double gz = (driving ? car.z : (ego_at ? ego_at->pos.z : 0.0)) + org[2]; + double yaw = std::atan2(d[1], d[0]); + obj_sel = world->add(obj_place, gp[0] + org[0], gp[1] + org[1], gz, yaw, sz, + src ? src->category : "vehicle.car"); + rebuild_objects(); + std::printf(" 객체 추가 #%d (%s)\n", obj_sel, world->assets[obj_place].name.c_str()); + } + obj_place = -1; + } else { // 선택: 가장 가까운 박스 + double best = 1e18; + obj_sel = -1; + for (size_t k = 0; k < world->objects.size(); ++k) { + double t = world->ray_hit(k, obj_ts, o, d); + if (t >= 0 && t < best) { best = t; obj_sel = int(k); } + } + if (obj_sel >= 0 && own_pose(obj_sel) && ground_hit(o, d, gp)) { + obj_drag = true; + obj_grab[0] = world->objects[obj_sel].pose.t[0] - (gp[0] + org[0]); + obj_grab[1] = world->objects[obj_sel].pose.t[1] - (gp[1] + org[1]); + } + } + } + if (lb && obj_drag && obj_sel >= 0 && mouse_ray(cx, cy, o, d) && ground_hit(o, d, gp)) { + world->objects[obj_sel].pose.t[0] = gp[0] + org[0] + obj_grab[0]; // 지면을 따라 옮긴다 + world->objects[obj_sel].pose.t[1] = gp[1] + org[1] + obj_grab[1]; + } + if (!lb) obj_drag = false; + obj_lb_prev = lb; + float wheel = ImGui::GetIO().WantCaptureMouse ? 0.0f : ImGui::GetIO().MouseWheel; + if (obj_sel >= 0 && wheel != 0 && own_pose(obj_sel)) { // 휠: 수직축 둘레 5° 씩 + auto& q = world->objects[obj_sel].pose.q; + double a = wheel * 5.0 * 3.14159265 / 180.0, cw = std::cos(a / 2), sw = std::sin(a / 2); + double nq[4] = {cw * q[0] - sw * q[3], cw * q[1] - sw * q[2], cw * q[2] + sw * q[1], cw * q[3] + sw * q[0]}; + for (int k = 0; k < 4; ++k) q[k] = nq[k]; + } + if (obj_sel >= 0 && glfwGetKey(w, GLFW_KEY_DELETE) == GLFW_PRESS) { + world->objects[obj_sel].deleted = true; + std::printf(" 객체 삭제 #%d\n", obj_sel); + obj_sel = -1; + rebuild_objects(); + } + } + if (ldown && dragging && driving && chase) { // 운전 중 드래그: 가로 = 차 주위 회전, 세로 = 시선 위아래 + chase_yaw -= float(cx - mx) * 0.005f; + chase_pitch = std::clamp(chase_pitch - float(cy - my) * 0.004f, -0.5f, 1.35f); + } + if (driving && chase && r.ui_ready() && !ImGui::GetIO().WantCaptureMouse) { + // 휠 한 칸 = zoom_step 배 (위로 굴리면 다가간다). 가운데 버튼 세로 드래그: 아래로 끌면 멀어진다 + float wh = (world && obj_sel >= 0) ? 0.0f : ImGui::GetIO().MouseWheel; // 객체를 고른 동안 휠은 객체 회전 + float z = chase_zoom * std::pow(g_cfg.chase_zoom_step, -wh); + bool mid = glfwGetMouseButton(w, GLFW_MOUSE_BUTTON_MIDDLE) == GLFW_PRESS; + if (mid && mid_prev) z *= std::exp(float(cy - my) * 0.005f); + mid_prev = mid; + chase_zoom = std::clamp(z, g_cfg.chase_zoom_min, g_cfg.chase_zoom_max); + } + if (ldown && dragging && !driving) { + if (mode == Mode::Bev) { + fc.yaw -= float(cx - mx) * 0.003f; + } else { + if (mode == Mode::Follow) mode = Mode::Free; + fc.yaw -= float(cx - mx) * 0.003f; + fc.pitch = std::clamp(fc.pitch - float(cy - my) * 0.003f, -1.5f, 1.5f); + } + } + dragging = ldown; + mx = cx; + my = cy; + + float speed = (glfwGetKey(w, GLFW_KEY_LEFT_SHIFT) == GLFW_PRESS ? 25.0f : 5.0f) * dt; + Vec3 fwd{std::cos(fc.yaw), std::sin(fc.yaw), 0}, left{-std::sin(fc.yaw), std::cos(fc.yaw), 0}; + Vec3 move{}; + if (glfwGetKey(w, GLFW_KEY_W) == GLFW_PRESS) move = move + fwd; + if (glfwGetKey(w, GLFW_KEY_S) == GLFW_PRESS) move = move - fwd; + if (glfwGetKey(w, GLFW_KEY_A) == GLFW_PRESS) move = move + left; + if (glfwGetKey(w, GLFW_KEY_D) == GLFW_PRESS) move = move - left; + if (glfwGetKey(w, GLFW_KEY_E) == GLFW_PRESS) move.z += 1; + if (glfwGetKey(w, GLFW_KEY_Q) == GLFW_PRESS) move.z -= 1; + if (dot(move, move) > 0 && !driving) { + if (mode == Mode::Follow) mode = Mode::Free; + if (mode == Mode::Bev) bev_height = std::clamp(bev_height - move.z * speed * 4, 10.0f, 400.0f); + else fc.pos = fc.pos + move * speed; + } + + // --- 차량 적분 (closed-loop: 제어 → 상태 → 다음 관측) + bool on_road = true; + float log_dev = 0; + if (driving) { + auto key = [&](int a, int b) { + return glfwGetKey(w, a) == GLFW_PRESS || glfwGetKey(w, b) == GLFW_PRESS; + }; + vkgs::VehicleControl ctl; + if (!init.record_screen.empty() || !init.record.empty()) dt = 0.05f; // 녹화: 고정 스텝 → 20 fps 영상 + if (seg < script.size()) { + dt = 0.05f; // 스크립트는 고정 스텝 — 렌더 속도와 무관하게 같은 궤적 + const auto& sc = script[seg]; + ctl.throttle = sc.throttle; + ctl.brake = sc.brake; + ctl.steer = sc.steer; + seg_time += dt; + if (seg_time >= sc.duration) { + seg_time = 0; + ++seg; + script_save = true; + } + } else if (autopilot) { + vkgs::AutopilotParams ap; + ap.target_v = std::min(g_cfg.autopilot_kmh, init.max_speed_kmh) / 3.6f; + ctl = vkgs::follow_path(car, log_path, autopilot_idx, ap); + if (key(GLFW_KEY_UP, GLFW_KEY_DOWN) || key(GLFW_KEY_LEFT, GLFW_KEY_RIGHT)) { // 화살표를 누르면 손으로 + autopilot = false; + std::printf(" 자동 주행 끔 (화살표 입력)\n"); + } + } else { + ctl.throttle = key(GLFW_KEY_UP, GLFW_KEY_W) ? 1.0f : 0.0f; + ctl.brake = key(GLFW_KEY_DOWN, GLFW_KEY_S) ? 1.0f : 0.0f; + // 누르고 있으면 초당 1.5 씩 커지고(0.67초에 최대), 떼면 초당 3 씩 가운데로 + float want = (key(GLFW_KEY_LEFT, GLFW_KEY_A) ? 1.0f : 0.0f) - (key(GLFW_KEY_RIGHT, GLFW_KEY_D) ? 1.0f : 0.0f); + float fdt = std::min(dt, 0.1f); + if (want != 0) { + if (want * steer_cmd < 0) steer_cmd = 0; // 반대로 꺾으면 바로 반대쪽부터 + steer_cmd = std::clamp(steer_cmd + want * 1.5f * fdt, -1.0f, 1.0f); + } else { + steer_cmd = steer_cmd > 0 ? std::max(0.0f, steer_cmd - 3.0f * fdt) : std::min(0.0f, steer_cmd + 3.0f * fdt); + } + ctl.steer = steer_cmd; + ctl.handbrake = glfwGetKey(w, GLFW_KEY_SPACE) == GLFW_PRESS; + } + vkgs::Contact contact_now = vkgs::Contact::None; + float left = std::min(dt, 0.1f); // 창을 끌다 멈춘 프레임에서 순간이동하지 않게 + update_others(drive_t0 + int64_t(sim_time * 1e6)); // 기록 궤적을 재생하는 차량은 프레임마다 자리가 바뀐다 + while (left > 1e-6f) { + float h = std::min(left, 0.01f); + float v0 = car.v; + vkgs::Vehicle prev = car; + // 적분 + 충돌 (drive.hpp step_with_contact — closed-loop 서버와 같은 판정) + vkgs::SubstepResult sr = vkgs::step_with_contact(car, ctl, h, cur_contact, obst, drivable, contact_params, &others); + const vkgs::ContactInfo& hit = sr.hit; + if (sr.blocked) { + if (touching == vkgs::Contact::None) { + ++n_collisions; + std::printf(" ✖ 충돌 — %s, %.0f km/h t=%.1fs (위치 %.1f, %.1f)\n", contact_name(hit.kind), + std::abs(prev.v) * 3.6f, sim_time, car.x, car.y); + } + touching = hit.kind; + contact_now = hit.kind; + free_time = 0; + } else { // cur_contact 는 step_with_contact 가 새 자세 것으로 바꿨다 + if (hit.kind != vkgs::Contact::None) contact_now = hit.kind; + if ((free_time += h) > 0.5f) touching = vkgs::Contact::None; + } + wheel_dist += car.v * h; + // 서스펜션: 목표 기울기 = 가속도 비례, 1.5 Hz·감쇠비 0.45 스프링으로 따라간다 + float a_long = (car.v - v0) / h; + float a_lat = car.v * car.v / car.geom.wheelbase * std::tan(car.delta); + float pitch_t = std::clamp(0.010f * a_long, -0.07f, 0.07f); // 2 m/s² → 1.1°, 풀 브레이크 → -4° + float roll_t = std::clamp(0.012f * a_lat, -0.07f, 0.07f); + const float wn = 2 * 3.14159265f * 1.5f, zeta = 0.45f; + body_pitch_v += (wn * wn * (pitch_t - body_pitch) - 2 * zeta * wn * body_pitch_v) * h; + body_pitch += body_pitch_v * h; + body_roll_v += (wn * wn * (roll_t - body_roll) - 2 * zeta * wn * body_roll_v) * h; + body_roll += body_roll_v * h; + left -= h; + } + sim_time += std::min(dt, 0.1f); + bool on_road_raw = true; + if (!drivable.empty()) { + // 연석 충돌과 같은 판정 (둘레를 0.15 m 줄여서) — 다르면 연석에 막혀 선 채로 '이탈' 경고가 뜬다 + on_road_raw = vkgs::vehicle_contact(car, vkgs::ObstacleGrid{}, drivable, true, g_cfg.curb_tol, g_cfg.obst_tol).kind != vkgs::Contact::Curb; + on_road = road.update(on_road_raw, std::min(dt, 0.1f)); + if (was_on_road && !on_road) std::printf(" ⚠ 주행가능영역 이탈 t=%.1fs\n", sim_time); + if (!was_on_road && on_road) std::printf(" ✔ 도로 복귀 t=%.1fs\n", sim_time); + was_on_road = on_road; + } + log_dev = log_path.size() > 1 ? vkgs::distance_to_polyline(log_path, car.x, car.y) : 0.0f; + Coverage cover = coverage_of(log_dev); + if (cover != last_cover) { + std::printf(" %s (원래 경로에서 %.1f m) t=%.1fs\n", coverage_name(cover), log_dev, sim_time); + last_cover = cover; + } + trail_clock += dt; + if (trail_clock > 0.1 && (trail.empty() || std::hypot(trail.back()[0] - car.x, trail.back()[1] - car.y) > 0.2f)) { + trail_clock = 0; + trail.push_back({car.x, car.y, car.z + 0.08f}); + if (trail.size() > 4000) trail.erase(trail.begin()); + } + if (drive_log.is_open()) + drive_log << sim_time << ',' << car.x << ',' << car.y << ',' << car.yaw << ',' << car.v << ',' + << car.delta * 57.29578f << ',' << ctl.throttle << ',' << ctl.brake << ',' + << int(on_road) << ',' << int(on_road_raw) << ',' << log_dev << ',' << int(cover) << ',' + << int(contact_now) << '\n'; // 이 프레임의 실제 접촉 (막힘 포함) + } + + RenderCam rc; + std::string label; + if (driving) { + if (chase) { + // 차 뒤 6.5 m, 위 1.9 m 에서 차 앞 8 m 를 본다. 학습 카메라는 약 1.5 m 높이에서 수평으로만 봤으므로 + // 높이 올릴수록 3DGS 가 못 본 각도라 노면 바늘·floater 가 드러난다 (3.5 m 에서 크게 깨짐, 2.2 m 에서도 + // 눈에 띔 — 2026-10-05 사용자 지적 '3D 품질이 낮다'). 미니밴 지붕(1.83 m) 너머가 겨우 보이는 높이. + // 카메라 방위·거리는 차를 늦게 따라간다 (방위 시상수 0.4초, 거리 0.8초). 거리는 속도에 따라 늘어 + // 가속하면 차가 앞으로 빠져나가고, 서면 다시 다가온다. + float fdt = std::min(dt, 0.1f); + float back_t = (g_cfg.chase_back + g_cfg.chase_back_per_mps * std::max(car.v, 0.0f)) * chase_zoom; + float height = g_cfg.chase_height * std::pow(chase_zoom, g_cfg.chase_zoom_height_exp); + if (!cam_init) { + cam_yaw = car.yaw; + cam_back = back_t; + cam_init = true; + } + float dyaw = std::remainder(car.yaw - cam_yaw, 2 * 3.14159265f); + cam_yaw += dyaw * (1 - std::exp(-fdt / g_cfg.chase_yaw_tau)); + cam_back += (back_t - cam_back) * (1 - std::exp(-fdt / g_cfg.chase_dist_tau)); + float c = std::cos(cam_yaw), s = std::sin(cam_yaw); + float ce = std::cos(cam_yaw + chase_yaw), se = std::sin(cam_yaw + chase_yaw); + Vec3 eye{car.x + g_ego.center_fwd * std::cos(car.yaw) - cam_back * ce, + car.y + g_ego.center_fwd * std::sin(car.yaw) - cam_back * se, + car.z + height}; + // 바라보는 점도 마우스로 돌린 방위(chase_yaw)를 따라 돈다 — 옆에서 볼 때 차가 화면 밖으로 나가지 않게 + Vec3 target{car.x + g_ego.center_fwd * std::cos(car.yaw) + g_cfg.chase_look_ahead * ce, + car.y + g_ego.center_fwd * std::sin(car.yaw) + g_cfg.chase_look_ahead * se, + car.z + g_cfg.chase_look_height}; + (void)c; + (void)s; + Vec3 fwd = normalize(target - eye); + Vec3 right = normalize(cross(fwd, Vec3{0, 0, 1})); + if (chase_pitch != 0) { // 시선만 위아래로 (오른쪽 축 둘레 회전) — 하늘·고가도로를 올려다본다 + Vec3 up = cross(right, fwd); + float cp = std::cos(chase_pitch), sp = std::sin(chase_pitch); + fwd = normalize(Vec3{fwd.x * cp + up.x * sp, fwd.y * cp + up.y * sp, fwd.z * cp + up.z * sp}); + right = normalize(cross(fwd, Vec3{0, 0, 1})); + } + rc.w2c = w2c_from(right, cross(fwd, right), fwd, eye); + rc.fx = rc.fy = f_free; + rc.cx = r.width() * 0.5f; + rc.cy = r.height() * 0.5f; + label = "DRIVE chase"; + } else { + const CamRig* cr = rig.rig_of(rig.cam_names[cam_idx]); + rc = drive_cam(cr ? *cr : rig.rigs.front()); + label = "DRIVE " + rig.cam_names[cam_idx]; + } + } else if (mode == Mode::Follow) { + const CamView* v = rig.find(frame, rig.cam_names[cam_idx]); + if (!v) v = &rig.views.front(); + rc.w2c = v->w2c; + // 카메라 파일 해상도와 렌더 해상도가 다르면 K 를 비례해서 맞춘다 + float sx = float(r.width()) / v->width, sy = float(r.height()) / v->height; + rc.fx = v->fx * sx; rc.fy = v->fy * sy; rc.cx = v->cx * sx; rc.cy = v->cy * sy; + fc.from_w2c(v->w2c); // 드래그를 시작하면 여기서 이어서 움직인다 + label = v->name; + } else { + if (mode == Mode::Bev) { + rc = bev_camera(ego_at, fc.pos, fc.yaw, bev_height, clip_above_ground, r.width(), r.height()); + label = "BEV " + std::to_string(int(bev_height)) + " m"; + if (clip_above_ground < 1e3f) { + char b[64]; + std::snprintf(b, sizeof(b), ", 높이 클립 %.1f m", clip_above_ground); + label += b; + } + } else { + rc.w2c = fc.w2c(); + rc.fx = rc.fy = f_free; + rc.cx = r.width() * 0.5f; + rc.cy = r.height() * 0.5f; + label = "free"; + } + } + + std::vector dyn; + bool asset = r.has_obj() || r.ego_model >= 0; // 자차를 실제 모양(3DGS 에셋/glTF)으로 그리면 박스 선은 끈다 + if (driving) { + EgoPose me; + me.pos = {car.x, car.y, car.z}; + me.yaw = car.yaw; + if (!asset) // 실제 차량 에셋이 있으면 박스 대신 그것을 그린다 + ego_box_lines(me, dyn, on_road ? std::array{1.0f, 0.55f, 0.0f, 1.0f} + : std::array{1.0f, 0.0f, 0.0f, 1.0f}); + // 주행 흔적 — 도로 위 연두, 이탈 중이면 빨강 (에셋이 있을 때 이탈 표시는 이것으로) + std::array tc = on_road ? std::array{0.6f, 1.0f, 0.2f, 1.0f} + : std::array{1.0f, 0.1f, 0.1f, 1.0f}; + for (size_t i = 0; i + 1 < trail.size(); ++i) + for (size_t k : {i, i + 1}) dyn.push_back({{trail[k][0], trail[k][1], trail[k][2]}, {tc[0], tc[1], tc[2], tc[3]}}); + rc.obj_xform = ego_asset_xform(car.x, car.y, car.z, car.yaw); + rc.show_obj = chase; // 자차 카메라 시점에서는 자기 차가 보이면 안 된다 + } else if (ego_at) { + if (!asset) ego_box_lines(*ego_at, dyn); + rc.obj_xform = ego_asset_xform(ego_at->pos.x, ego_at->pos.y, ego_at->pos.z, ego_at->yaw); + rc.show_obj = mode != Mode::Follow; // 재생 중 BEV/자유 시점에서는 로그 위치에 그린다 + } + // 다른 차량: 재생 중엔 현재 프레임, 운전 중엔 로그 시계(출발 시각 + 경과)로 — 아직 자차에 반응하지 않는다 + int agent_frame = frame; + if (driving && !rig.ego.empty()) { + int64_t t0 = 0; + for (const auto& e : rig.ego) + if (e.frame == drive_start_frame) t0 = e.ts; + int64_t t = t0 + int64_t(sim_time * 1e6); + int64_t best = INT64_MAX; + for (const auto& e : rig.ego) + if (std::llabs(e.ts - t) < best) { + best = std::llabs(e.ts - t); + agent_frame = e.frame; + } + } + auto ag = rig.agents.find(agent_frame); + { + EgoPose me; + const EgoPose* ego_draw = nullptr; + if (driving) { + me.pos = {car.x, car.y, car.z}; + me.yaw = car.yaw; + ego_draw = &me; + } else if (ego_at) { + ego_draw = ego_at; + } + r.set_vehicles(vehicle_draws(gltf, ag == rig.agents.end() ? nullptr : &ag->second, ego_draw)); + if (ego_draw) set_ego_light(r, ego_draw->pos.x, ego_draw->pos.y, ego_draw->pos.z, ego_draw->yaw); + if (gltf.ego && !gltf.ego->wheels.empty()) // 운전 중이 아니면 바퀴·차체는 기본 자세 + r.set_ego_parts(driving ? ego_parts(*gltf.ego, g_ego.length, wheel_dist, car.delta, body_pitch, body_roll) + : ego_parts(*gltf.ego, g_ego.length, 0, 0, 0, 0)); + r.set_shadows(vehicle_shadows(gltf, ag == rig.agents.end() ? nullptr : &ag->second, ego_draw)); + } + if (world && obj_edit && obj_sel >= 0) { // 선택한 객체 박스 (노랑) + vkgs::ObjKeyframe p; + const auto& so = world->objects[obj_sel]; + if (!so.deleted && world->pose_at(so, obj_ts, p)) { + float m[16]; + world->xform_of(so, p, m); + float hl = so.size[1] * 0.5f, hw = so.size[0] * 0.5f, h = so.size[2]; + float cs[8][3]; + for (int k = 0; k < 8; ++k) { + float l3[3] = {(k & 1) ? hl : -hl, (k & 2) ? hw : -hw, (k & 4) ? h : 0.0f}; + for (int a = 0; a < 3; ++a) cs[k][a] = m[a] * l3[0] + m[4 + a] * l3[1] + m[8 + a] * l3[2] + m[12 + a]; + } + const int E[12][2] = {{0, 1}, {2, 3}, {4, 5}, {6, 7}, {0, 2}, {1, 3}, {4, 6}, {5, 7}, {0, 4}, {1, 5}, {2, 6}, {3, 7}}; + for (const auto& e : E) + for (int v : e) dyn.push_back({{cs[v][0], cs[v][1], cs[v][2]}, {1.0f, 0.9f, 0.1f, 1.0f}}); + } + } + // 레이캐스트 센서: 운전 중이면 자차, 재생 중이면 기록 자차 자세에서 (보기를 켰을 때만 매 프레임) + bool have_ego = driving || ego_at; + vkgs::EgoState sensor_ego; + if (driving) + sensor_ego = {car.x, car.y, car.z, car.yaw, car.v, car.v * std::tan(car.delta) / car.geom.wheelbase}; + else if (ego_at) + sensor_ego = {ego_at->pos.x, ego_at->pos.y, ego_at->pos.z, ego_at->yaw, 0, 0}; + // 노면에 앉힌다 (z·pitch·roll — 기울어진 길에서 라이다 빔이 노면에 닿는 거리가 맞게, sensors.hpp settle_on_ground) + sensor_ego = vkgs::settle_on_ground(g_voxels, sensor_ego, g_ego.geom.wheelbase, g_ego.geom.half_width); + std::vector sensor_boxes; + for (const auto& f : others) // 장면 객체(차량) — 높이·속도는 아직 없다 (1.6 m, 정지로 본다) + sensor_boxes.push_back({f.x, f.y, sensor_ego.z, f.yaw, f.half_len, f.half_wid, 1.6f, 0, 0}); + if (have_ego && !g_voxels.empty() && (sensors.lidar || sensors.radar)) { + sensors.scan(sensor_ego, sensor_boxes, false); + sensors.draw(dyn, sensor_ego.z); + } + r.set_dynamic_lines(dyn); + if (segs) { // 구간 모델: 자차(운전) · 기록 자차(재생) · 자유 시점 카메라 위치로 구간을 고른다 + Vec3 p = driving ? Vec3{car.x, car.y, car.z} : (mode == Mode::Follow && ego_at) ? ego_at->pos : fc.pos; + segs->update(r, p.x, p.y); + } + + // 6캠 관측 저장 — VLA 모델이 받게 될 입력과 같은 것 + bool seg_done = script_save; + if (script_save) { + save_obs = true; + script_save = false; + } + if (save_obs) { + char dir[512]; + std::snprintf(dir, sizeof(dir), "%s/obs_%03d", shot_dir.c_str(), obs_count++); + // 관측은 모델 입력이므로 맵 선·자차 박스 없이 카메라 영상만 그린다 + bool lines_was = r.show_lines; + int mode_was = r.camera_mode; + r.show_lines = false; + r.camera_mode = 0; + for (const auto& cr : rig.rigs) r.render(drive_cam(cr), std::string(dir) + "/" + cr.name + ".png", false); + r.show_lines = lines_was; + r.camera_mode = mode_was; + if (have_ego && !g_voxels.empty()) { // 라이다·레이더도 같은 순간으로 (보기와 상관없이 설정된 센서 전부) + bool l = sensors.lidar, rd = sensors.radar; + sensors.scan(sensor_ego, sensor_boxes, true); + sensors.save(dir); + sensors.lidar = l, sensors.radar = rd; + } + std::ofstream meta(std::string(dir) + "/state.txt"); + meta << "t " << sim_time << "\nx " << car.x << "\ny " << car.y << "\nyaw " << car.yaw << "\nv " << car.v + << "\nsteer_rad " << car.delta << "\non_road " << on_road << "\nlog_dev_m " << log_dev << "\n"; + std::printf(" 6캠 관측 저장 → %s\n", dir); + } + + bool do_shot = shot || (autoshot > 0 && n_frame_drawn == autoshot) || seg_done; + std::string png; + if (do_shot) png = shot_dir + "/shot_" + std::to_string(shot_count++) + ".png"; + if (!init.record_screen.empty()) { + char b[64]; + std::snprintf(b, sizeof(b), "/frame_%05d.png", n_frame_drawn); + r.screen_png = init.record_screen + b; + } + if (!init.record.empty() && !do_shot) { + char b[64]; + std::snprintf(b, sizeof(b), "/frame_%05d.png", n_frame_drawn); + png = init.record + b; + } + r.weather_time += std::min(dt, 0.1f); + // 장면 객체(차량 에셋)의 이번 프레임 자세 — 운전 중이면 출발 시각 + 경과 시간, 재생 중이면 그 프레임 시각 + if (world) { + int64_t ts = driving ? drive_t0 + int64_t(sim_time * 1e6) : (ego_at ? ego_at->ts : 0); + obj_ts = ts; + world->table_at(ts, obj_table); + r.set_object_table(obj_table); + } + // 미리보기 (전방 3대·BEV): 켜진 것만 프레임마다 한 장씩 돌아가며 다시 그린다 (다 그리면 프레임이 몇 배 느려진다) + if ((front_cams || bev_pip) && r.ui_ready()) { + static const char* names[3] = {"CAM_FRONT_LEFT", "CAM_FRONT", "CAM_FRONT_RIGHT"}; + int slots[Renderer::kPip], ns = 0; + if (front_cams) for (int k = 0; k < 3; ++k) slots[ns++] = k; + if (bev_pip) slots[ns++] = 3; + int k = slots[pip_turn++ % ns]; + RenderCam pc; + bool ok = false; + if (k == 3) { // BEV: 운전 중이면 차, 아니면 기록된 자차 포즈 위 + if (driving) { + pc = bev_camera(nullptr, Vec3{car.x, car.y, car.z}, car.yaw, g_cfg.bev_pip_height, clip_above_ground, r.width(), r.height()); // 캐노피 클립은 메인 BEV 와 같이 (H) + pc.obj_xform = ego_asset_xform(car.x, car.y, car.z, car.yaw); + ok = true; + } else if (ego_at) { + pc = bev_camera(ego_at, ego_at->pos, ego_at->yaw, g_cfg.bev_pip_height, clip_above_ground, r.width(), r.height()); // 캐노피 클립은 메인 BEV 와 같이 (H) + pc.obj_xform = ego_asset_xform(ego_at->pos.x, ego_at->pos.y, ego_at->pos.z, ego_at->yaw); + ok = true; + } + pc.show_obj = true; // 위에서는 자차가 보인다 + } else if (driving) { + if (const CamRig* cr = rig.rig_of(names[k])) { + pc = drive_cam(*cr); + ok = true; + } + } else if (const CamView* v = rig.find(frame, names[k])) { + pc.w2c = v->w2c; + float sx = float(r.width()) / v->width, sy = float(r.height()) / v->height; + pc.fx = v->fx * sx; pc.fy = v->fy * sy; pc.cx = v->cx * sx; pc.cy = v->cy * sy; + ok = true; + } + if (ok) { + if (k != 3) pc.show_obj = false, pc.sensor = true; // 자차 카메라 시점 — 자기 차는 안 보이고, 카메라 모드는 여기만 + r.render(pc, "", false, k); + } + } + if (r.ui_ready()) { + if (pressed(GLFW_KEY_F1)) show_ui = !show_ui; + if (pressed(GLFW_KEY_F3)) r.camera_mode = (r.camera_mode + 1) % 4; + if (driving && pressed(GLFW_KEY_T)) { + autopilot = !autopilot; + autopilot_idx = 0; + std::printf(" 자동 주행 %s (기록 궤적 따라가기, %.0f km/h)\n", autopilot ? "켬" : "끔", g_cfg.autopilot_kmh); + } + ImGui_ImplVulkan_NewFrame(); + ImGui_ImplGlfw_NewFrame(); + ImGui::NewFrame(); + if (pressed(GLFW_KEY_V)) front_cams = !front_cams; + if (pressed(GLFW_KEY_N)) bev_pip = !bev_pip; + g_panel_right = 0; + if (show_ui) weather_panel(r.weather, front_cams, bev_pip, r.camera_mode); + if (show_ui) sensor_panel(sensors); + if (!focused) { // 키보드 포커스가 없으면 운전 키가 다른 창(터미널 등)으로 간다 — 백그라운드에서 띄운 창 (2026-10-12) + const char* msg = "키 입력이 이 창으로 오지 않습니다 — 창을 한 번 클릭하세요"; + ImVec2 ds = ImGui::GetIO().DisplaySize, ts = ImGui::CalcTextSize(msg); + ImVec2 p0{(ds.x - ts.x) * 0.5f - 12, ds.y * 0.42f - 10}, p1{(ds.x + ts.x) * 0.5f + 12, ds.y * 0.42f + ts.y + 10}; + ImGui::GetForegroundDrawList()->AddRectFilled(p0, p1, IM_COL32(0, 0, 0, 190), 6); + ImGui::GetForegroundDrawList()->AddText(ImVec2(p0.x + 12, p0.y + 10), IM_COL32(255, 220, 80, 255), msg); + } + pip_strip(r, front_cams, bev_pip, g_cfg.bev_pip_height); + if (world && pressed(GLFW_KEY_K)) obj_window = !obj_window; + if (world && obj_window) { + ImGui::SetNextWindowSize(ImVec2(340, 0), ImGuiCond_FirstUseEver); + ImGui::Begin("장면 객체 (K)", &obj_window); + int alive = 0; + for (const auto& so : world->objects) alive += !so.deleted; + ImGui::Text("객체 %d개 (지운 것 %d), 에셋 %zu개", alive, int(world->objects.size()) - alive, world->assets.size()); + ImGui::Checkbox("편집 모드 (왼쪽 클릭 선택·드래그 이동, 휠 회전, Delete 삭제, 카메라는 오른쪽 드래그)", &obj_edit); + if (obj_edit) { + if (ImGui::BeginCombo("에셋", world->assets[std::clamp(obj_lib, 0, int(world->assets.size()) - 1)].name.c_str())) { + for (int k = 0; k < int(world->assets.size()); ++k) + if (ImGui::Selectable(world->assets[k].name.c_str(), k == obj_lib)) obj_lib = k; + ImGui::EndCombo(); + } + if (ImGui::Button(obj_place >= 0 ? "지면을 클릭하세요..." : "추가 (다음 클릭 위치에)")) obj_place = obj_lib; + if (obj_sel >= 0) { + const auto& so = world->objects[obj_sel]; + ImGui::Text("선택 #%d %s %s %.1f×%.1f m", obj_sel, so.category.c_str(), + world->assets[so.asset].name.c_str(), so.size[1], so.size[0]); + if (ImGui::Button("삭제 (Delete)")) { + world->objects[obj_sel].deleted = true; + obj_sel = -1; + rebuild_objects(); + } + } + if (ImGui::Button("저장 (objects.json)")) { + world->save(); + std::cout << " 장면 객체 저장 → " << (world->dir / "objects.json").string() << "\n"; + } + } + ImGui::End(); + } + if (pressed(GLFW_KEY_F2)) graph_window = !graph_window; + if (graph_window) render_graph_window(r, graph_window); + ImGui::Render(); + } + last_rc = rc; // 다음 프레임의 마우스 선택용 + have_rc = true; + Timing t = r.render(rc, png, true); + FrameMark; // Tracy: 화면 프레임 경계 + TracyPlot("visible gaussians", int64_t(t.visible)); + TracyPlot("speed km/h", double(car.v * 3.6f)); + ++n_frame_drawn; + if (do_shot) std::cout << " 스크린샷 → " << png << "\n"; + if (autoshot > 0 && n_frame_drawn > autoshot) break; + if (!script.empty() && seg >= script.size() && seg_done) { + std::printf(" 스크립트 끝 — t %.1fs, 위치 (%.1f, %.1f), 로그 궤적에서 %.1f m, %s\n", sim_time, car.x, car.y, + log_dev, on_road ? "도로 위" : "도로 이탈"); + break; + } + + char title[512]; + if (driving) + std::snprintf(title, sizeof(title), + "%svkgs_viewer — %s | %.0f/%.0f km/h 조향 %+.0f° | %s%s 충돌 %d회 | %s (경로에서 %.1f m) | t %.1fs | %.0f ms", + focused ? "" : "[창을 클릭하세요 — 키 입력 없음] ", label.c_str(), car.v * 3.6f, init.max_speed_kmh, car.delta * 57.29578f, + drivable.empty() ? "맵 없음" : (on_road ? "도로 위" : "도로 이탈!"), + touching != vkgs::Contact::None ? (touching == vkgs::Contact::Vehicle ? " ✖ 차량과 충돌" : touching == vkgs::Contact::Obstacle ? " ✖ 구조물에 막힘" : " ✖ 연석에 막힘") : "", + n_collisions, + coverage_name(coverage_of(log_dev)), log_dev, sim_time, + t.preprocess_ms + t.sort_ms + t.raster_ms); + else + std::snprintf(title, sizeof(title), + "vkgs_viewer — %s frame %d/%d%s | visible %u | pre %.1f + sort %.1f + raster %.1f ms", + label.c_str(), frame, std::max(rig.n_frames - 1, 0), playing ? " ▶" : "", t.visible, + t.preprocess_ms, t.sort_ms, t.raster_ms); + glfwSetWindowTitle(w, title); + } + return 0; +} + +// ============================================================== closed-loop 서버 (--serve DIR) +// HUGSIM 식 평가: 주행 알고리즘(Python, nusim.sim.closed_loop.VkgsEnv)이 다른 프로세스에서 제어를 보내고, 서버는 +// 창 운전과 같은 차량 모델·충돌로 적분한 뒤 6캠 관측을 공유 메모리에 그린다. 프로토콜은 serve.hpp. +struct ServeOptions { + std::string dir; // FIFO (cmd, resp) + std::string shm; // 관측 파일 디렉터리 + float max_speed_kmh = 30.0f; + bool curb = true; +}; + +int run_server(Renderer& r, const Rig& rig, const vkgs::Drivable& drivable, const vkgs::ObstacleGrid& obst, + SegmentStreamer* segs, const ServeOptions& opt) { + if (rig.rigs.empty()) throw std::runtime_error("--serve: 카메라 리그(R 줄)가 없음 — nusim render export 를 다시"); + fs::create_directories(opt.dir); + fs::create_directories(opt.shm); + const std::string cmd_path = opt.dir + "/cmd", resp_path = opt.dir + "/resp"; + for (const std::string& p : {cmd_path, resp_path}) + if (!fs::exists(p) && mkfifo(p.c_str(), 0600) != 0) throw std::runtime_error("mkfifo 실패: " + p); + std::printf("closed-loop 서버 %s (관측 %s, %dx%d, 카메라 %zu대) — 명령을 기다림\n", opt.dir.c_str(), opt.shm.c_str(), + r.width(), r.height(), rig.rigs.size()); + std::fflush(stdout); + std::ifstream in(cmd_path); // 쓰는 쪽(Python)이 열 때까지 기다린다 — 둘 다 cmd 를 먼저 연다 + std::ofstream out(resp_path); // 읽는 쪽이 열 때까지 기다린다 + if (!in || !out) throw std::runtime_error("FIFO 를 열 수 없음: " + opt.dir); + + const vkgs::ContactParams cp{opt.curb, g_cfg.curb_tol, g_cfg.obst_tol, g_cfg.graze_samples}; + std::vector> log_path; + for (const auto& e : rig.ego) log_path.push_back({e.pos.x, e.pos.y}); + vkgs::Vehicle car; + vkgs::ContactInfo cur; + std::vector others; // 다른 차량 (actors 명령) + double sim_time = 0; + int n_collisions = 0, n_obs = 0; + float accel = 0, free_time = 1; + bool touching = false, blocked = false, have_car = false; + vkgs::Contact contact = vkgs::Contact::None; + int contact_n = 0; + double render_ms = 0; + std::string obs_dir; + std::vector rgb; + SensorUi sensors; + r.show_lines = false; // 관측은 모델 입력 — 맵 선 없이 카메라 영상만 + + auto rank = [](vkgs::Contact c) { // 보고 우선순위: 다른 차량 > 장애물 > 연석 + return c == vkgs::Contact::Vehicle ? 3 : c == vkgs::Contact::Obstacle ? 2 : c == vkgs::Contact::Curb ? 1 : 0; + }; + auto drive_cam = [&](const CamRig& cr) { // run_window 의 drive_cam 과 같다 (평지 자세 — 학습 카메라도 기록 z 에 있었다) + RenderCam rc; + rc.w2c = rigid_inverse(ego_matrix(car.x, car.y, car.z, car.yaw) * cr.cam2ego); + float sx = float(r.width()) / cr.width, sy = float(r.height()) / cr.height; + rc.fx = cr.fx * sx; rc.fy = cr.fy * sy; rc.cx = cr.cx * sx; rc.cy = cr.cy * sy; + rc.sensor = true; + return rc; + }; + auto ego_state = [&]() { + vkgs::EgoState e{car.x, car.y, car.z, car.yaw, car.v, car.v * std::tan(car.delta) / car.geom.wheelbase}; + return vkgs::settle_on_ground(g_voxels, e, car.geom.wheelbase, car.geom.half_width); + }; + auto observe = [&](bool with_sensors) { + auto t0 = std::chrono::steady_clock::now(); + if (segs) segs->update(r, car.x, car.y, true); // 자차 위치의 구간 (경계를 넘으면 읽힐 때까지 기다린다) + set_ego_light(r, car.x, car.y, car.z, car.yaw); + r.weather_time = float(sim_time); + for (const auto& cr : rig.rigs) { + r.render(drive_cam(cr), "", false, -1, &rgb); + std::ofstream f(opt.shm + "/" + cr.name + ".rgb", std::ios::binary); + f.write(reinterpret_cast(rgb.data()), std::streamsize(rgb.size())); + if (!f) throw std::runtime_error("관측 저장 실패: " + opt.shm); + } + if (with_sensors && !g_voxels.empty()) { + std::vector boxes; + vkgs::EgoState e = ego_state(); + for (const auto& f : others) boxes.push_back({f.x, f.y, e.z, f.yaw, f.half_len, f.half_wid, 1.6f, 0, 0}); + sensors.scan(e, boxes, true); + sensors.save(opt.shm); + } + ++n_obs; + obs_dir = opt.shm; + render_ms = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + }; + auto state_reply = [&](double step_ms) { + vkgs::EgoState e = ego_state(); + bool on_road = drivable.empty() || + vkgs::vehicle_contact(car, vkgs::ObstacleGrid{}, drivable, true, cp.curb_tol, cp.obst_tol).kind != vkgs::Contact::Curb; + float dev = log_path.size() > 1 ? vkgs::distance_to_polyline(log_path, car.x, car.y) : 0.0f; + vkgs::ServeReply rep; + rep.add("t", sim_time).add("x", car.x).add("y", car.y).add("z", car.z).add("yaw", car.yaw).add("v", car.v) + .add("yaw_rate", e.yaw_rate).add("accel", accel).add("delta", car.delta).add("contact", int(contact)) + .add("contact_n", contact_n).add("blocked", int(blocked)).add("collisions", n_collisions) + .add("on_road", int(on_road)).add("log_dev", dev, 3).add("ground_z", e.z, 3).add("pitch", e.pitch) + .add("roll", e.roll).add("seg", segs ? segs->map.segs[segs->active].k : -1) + .add("step_ms", step_ms, 2).add("render_ms", render_ms, 2).add("n_obs", n_obs).add("obs", obs_dir); + return rep.line(); + }; + + std::string line; + while (std::getline(in, line)) { + auto t0 = std::chrono::steady_clock::now(); + std::string reply; + try { + vkgs::ServeCommand c = vkgs::parse_command(line); + if (c.name.empty()) continue; + obs_dir.clear(); + render_ms = 0; + if (c.name == "hello") { + std::string cams, lid, rad; + for (const auto& cr : rig.rigs) cams += (cams.empty() ? "" : ",") + cr.name; + for (const auto& l : g_cfg.lidars) lid += (lid.empty() ? "" : ",") + l.mount.name; + for (const auto& l : g_cfg.radars) rad += (rad.empty() ? "" : ",") + l.mount.name; + const vkgs::VehicleGeom& g = g_ego.geom; + vkgs::ServeReply rep; + rep.add("version", vkgs::kServeProtocolVersion).add("width", r.width()).add("height", r.height()) + .add("cams", cams).add("shm", opt.shm).add("frames", int(rig.ego.size())) + .add("wheelbase", g.wheelbase).add("rear", g.rear).add("front", g.front).add("half_width", g.half_width) + .add("max_speed", opt.max_speed_kmh / 3.6f).add("max_accel", car.accel).add("max_brake", vkgs::Vehicle::kBrake) + .add("max_steer", vkgs::Vehicle::kMaxSteer).add("steer_rate", vkgs::Vehicle::kSteerRate) + .add("curb", int(cp.curb)).add("curb_tol", cp.curb_tol).add("obst_tol", cp.obst_tol).add("graze", cp.graze) + .add("obstacles", int(!obst.empty())).add("drivable", int(!drivable.empty())) + .add("lidar", g_voxels.empty() ? "" : lid).add("radar", g_voxels.empty() ? "" : rad) + .add("segments", segs ? int(segs->map.segs.size()) : 0); + reply = rep.line(); + } else if (c.name == "reset") { + car = {}; + car.geom = g_ego.geom; + car.max_speed = opt.max_speed_kmh / 3.6f; + if (c.has("frame")) { + int fr = int(c.num("frame")); + const EgoPose* e = nullptr; + for (const auto& p : rig.ego) + if (p.frame == fr) e = &p; + if (!e) throw std::runtime_error("reset: 리그에 프레임 " + std::to_string(fr) + " 의 자차 포즈가 없음"); + car.x = e->pos.x; car.y = e->pos.y; car.z = e->pos.z; car.yaw = e->yaw; + } + car.x = c.num("x", car.x); + car.y = c.num("y", car.y); + car.z = c.num("z", car.z); + car.yaw = c.num("yaw", car.yaw); + car.v = std::clamp(c.num("v", 0.0f), 0.0f, car.max_speed); + car.delta = c.num("delta", 0.0f); + sim_time = 0; + n_collisions = 0; + accel = 0; + touching = blocked = false; + free_time = 1; + cur = vkgs::vehicle_contact(car, obst, drivable, cp.curb, cp.curb_tol, cp.obst_tol, &others); + contact = cur.kind; + contact_n = cur.total(); + have_car = true; + if (c.flag("render", true)) observe(c.flag("sensors", false)); + reply = state_reply(0); + } else if (!have_car && (c.name == "step" || c.name == "render" || c.name == "state")) { + throw std::runtime_error(c.name + ": 먼저 reset"); + } else if (c.name == "step") { + float dt = c.num("dt"); + if (!(dt > 0 && dt <= 10)) throw std::runtime_error("step: dt 는 (0, 10] 초"); + bool phys = c.has("accel") || c.has("delta"); + float a_cmd = c.num("accel", 0.0f), d_cmd = c.num("delta", 0.0f); + vkgs::VehicleControl ctl; + if (!phys) { + ctl.throttle = std::clamp(c.num("throttle", 0.0f), 0.0f, 1.0f); + ctl.brake = std::clamp(c.num("brake", 0.0f), 0.0f, 1.0f); + ctl.steer = std::clamp(c.num("steer", 0.0f), -1.0f, 1.0f); + ctl.handbrake = c.flag("handbrake", false); + } + float v0 = car.v, left = dt; + contact = vkgs::Contact::None; + contact_n = 0; + blocked = false; + while (left > 1e-6f) { + float h = std::min(left, 0.01f); + if (phys) ctl = vkgs::control_for(car, a_cmd, d_cmd); // 속도가 바뀌면 페달·핸들 환산도 바뀐다 + vkgs::SubstepResult sr = vkgs::step_with_contact(car, ctl, h, cur, obst, drivable, cp, &others); + if (sr.blocked) { + if (!touching) ++n_collisions; // 막힌 채로 밀고 있는 동안은 한 번만 + touching = blocked = true; + free_time = 0; + } else if ((free_time += h) > 0.5f) { + touching = false; + } + if (rank(sr.kind) > rank(contact)) contact = sr.kind; + contact_n = std::max(contact_n, sr.hit.total()); + left -= h; + } + sim_time += dt; + accel = (car.v - v0) / dt; + if (c.flag("render", false)) observe(c.flag("sensors", false)); + reply = state_reply(std::chrono::duration(std::chrono::steady_clock::now() - t0).count()); + } else if (c.name == "render") { + observe(c.flag("sensors", false)); + reply = state_reply(0); + } else if (c.name == "state") { + reply = state_reply(0); + } else if (c.name == "actors") { + others.clear(); + for (const auto& b : vkgs::parse_boxes(c.has("boxes") ? c.str("boxes") : "")) + others.push_back({b[0], b[1], b[2], b[3], b[4]}); + if (have_car) cur = vkgs::vehicle_contact(car, obst, drivable, cp.curb, cp.curb_tol, cp.obst_tol, &others); + reply = vkgs::ServeReply().add("actors", int(others.size())).line(); + } else if (c.name == "close") { + out << vkgs::ServeReply().add("bye", 1).line() << std::flush; + break; + } else { + throw std::runtime_error("알 수 없는 명령: " + c.name); + } + } catch (const std::exception& e) { + reply = vkgs::serve_error(e.what()); + } + out << reply << std::flush; + if (!out) break; // 읽는 쪽이 사라졌다 + } + r.flush_png(); + std::error_code ec; + for (const auto& cr : rig.rigs) fs::remove(opt.shm + "/" + cr.name + ".rgb", ec); + for (const auto& l : g_cfg.lidars) fs::remove(opt.shm + "/" + l.mount.name + ".bin", ec); + for (const auto& l : g_cfg.radars) fs::remove(opt.shm + "/" + l.mount.name + ".txt", ec); + fs::remove(opt.shm, ec); // 비었을 때만 지워진다 + std::printf("closed-loop 서버 종료 — 관측 %d번\n", n_obs); + return 0; +} + +void usage() { + std::cout << "사용법: vkgs_viewer [옵션]\n" + " --cams FILE 카메라 파일 (nusim render export 가 만든다)\n" + " --lines FILE 맵 선 파일\n" + " --offscreen 창 없이 카메라 파일의 뷰를 전부 PNG 로\n" + " --out DIR 오프스크린 출력 디렉터리 (기본 outputs/vk)\n" + " --only NAME 오프스크린에서 이 카메라만 (예: CAM_FRONT)\n" + " --size WxH 렌더 해상도 (기본: 카메라 파일 해상도, 없으면 1600x900)\n" + " --shot-dir DIR 창 모드 스크린샷 위치\n" + " --autoshot N 창 모드에서 N 프레임 뒤 스크린샷을 찍고 종료 (점검용)\n" + " --mode M 시작 시점: follow(기본) | free | bev\n" + " --cam NAME follow 모드 카메라 (기본 CAM_FRONT)\n" + " --frame N 시작 프레임\n" + " --bev-height H BEV 높이 m (기본 60)\n" + " --play 궤적 재생 상태로 시작\n" + " --clip H BEV 높이 클립 m (기본 2.5, 큰 값이면 끔)\n" + " --frames A:B 오프스크린에서 이 프레임 범위만 (B 미포함)\n" + " --bev 오프스크린에서 프레임마다 BEV 도 그린다 (f####_BEV.png)\n" + " --drivable FILE 주행가능영역 (직접 운전 때 도로 이탈 판정 + 연석 충돌)\n" + " --obstacles FILE 장애물 지도 PGM (nusim/sim/obstacles.py) — 건물·기둥·벽에 닿으면 멈춘다\n" + " --no-curb 연석(주행가능영역 경계) 충돌을 끈다 (이탈 경고만)\n" + " --config FILE 시뮬레이터 설정 JSON (기본 configs/sim.json): 날씨 프리셋·하늘·3인칭 카메라·충돌 등\n" + " --graph OUT.dot 렌더 그래프(설정 render_graph, 기본 configs/render_graph.json)를 Graphviz 로 쓰고 끝냄\n" + " --reflect OUT 셰이더 리플렉션을 JSON 으로 쓰고 끝냄\n" + " --objects DIR 장면 객체 (nusim recon train --objects vehicles 가 내보낸 objects/) — 차량을 에셋으로\n" + " --pass NAME=on|off 렌더 그래프 패스 켜기·끄기 (예: fxaa=off, camera_isp=on)\n" + " --keep-sky-gaussians sky_gaussians.u8 가 있어도 하늘 가우시안을 그린다 (학습기 비교용)\n" + " --sky-mode M 배경 하늘: sim (시뮬레이션, 기본) | learned (--sky 큐브맵, 학습기 일치 검사) | ibl\n" + " --weather SPEC 합성 날씨: clear|overcast|dusk|night|rain|heavy_rain|fog|night_rain|snow|heavy_snow\n" + " --camera-mode M 카메라 모드로 시작: rgb|depth|semantic|gray (창에서는 F3)\n" + " 또는 daylight=0~1,cloud=,rain=,fog=,wet=,snow=,snow_cover=,headlights=-1|0|1\n" + " --drive 직접 운전 모드로 시작 (--frame 위치에서)\n" + " --cam-view 운전 시작 시점을 차 뒤 대신 카메라로\n" + " --controls FILE 키보드 대신 스크립트 제어 (줄마다: 지속초 throttle brake steer)\n" + " --record DIR 창 모드에서 매 프레임을 PNG 로 저장 (데모 영상용)\n" + " --record-screen DIR 창 화면 그대로 (UI · 전방 3대 · BEV 미리보기 줄 포함) 매 프레임 PNG\n" + " --autopilot 기록 궤적을 따라 자동 주행으로 시작 (창에서 T, 화살표를 누르면 꺼짐)\n" + " --show-sensors 라이다 · 레이더 보기를 켠 채로 시작\n" + " --hide-panels 패널을 숨기고 시작 (F1) — 영상 녹화용\n" + " --max-speed KMH 운전 최고 속도 (기본 30 — 재구성 구역이 경로를 따라 수십 m 뿐이다)\n" + " --ego-asset PLY 자차를 박스 대신 이 3DGS 차량 에셋으로 (nusim asset extract)\n" + " --ibl DIR IBL (nusim render ibl): 환경 배경 + PBR 조명\n" + " --gltf FILE 다른 차량을 이 glTF 메시로 (카메라 파일의 A 줄 = 움직이는 차 박스). --ibl 필요\n" + " --voxels FILE 레이캐스트 센서(라이다·레이더)의 복셀 월드 (nusim/sim/voxels.py). 센서는 sim.json sensors\n" + " --segments FILE 구간 모델 목록 (segment_map.hpp, nusim render view 가 만든다): 자차 위치의 구간으로 바꿔 그린다.\n" + " ply 인자는 구간 0 (공통 원점)\n" + " --sky FILE 학습된 하늘 큐브맵 (nusim recon train --sky 의 sky.ktx2) 을 배경으로\n" + " --ego-gltf FILE 자차를 이 glTF 메시로 (PBR + IBL). --ibl 필요. 축거·차체 범위는 모델 바퀴에서\n" + " --ego-length M 자차 glTF 길이 (기본 4.1 = nuScenes Renault Zoe, 카니발급 미니밴 5.15)\n" + " --exposure X 메시 조명 노출 배율 (기본 1)\n" + " --pbr-spheres IBL 검증용 PBR 구 (거칠기 0→1 × 비금속/금속) 를 자차 앞 6 m 에\n" + " --no-env 환경 배경은 그리지 않는다 (조명만)\n" + " --env-lod L 배경에 쓸 specular mip (기본 0)\n" + " --serve DIR 창 없이 closed-loop 서버: DIR/cmd·DIR/resp FIFO 로 reset/step/render (serve.hpp 프로토콜,\n" + " nusim.sim.closed_loop.VkgsEnv). 6캠 관측은 --shm 에 .rgb (H×W×3) 로\n" + " --shm DIR 서버 관측 디렉터리 (기본 /dev/shm/vkgs_)\n"; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + usage(); + return 1; + } + std::string obstacles_path, weather_spec, voxels_path, camera_mode_spec; + std::string objects_dir; // 장면 객체 (nusim.recon.objects 가 내보낸 objects/) + std::string ply = argv[1], cams, segments_path, lines, drivable_path, ego_asset, ibl_dir, out = "outputs/vk", only, shot_dir = "outputs/vk"; + bool pbr_spheres = false, no_env = false; + std::string gltf_path, ego_gltf_path, sky_path; + float ego_length = 4.1f; // nuScenes 자차 Renault Zoe + float exposure = 1.0f; + float env_lod = 0.0f; + bool offscreen = false, bev = false; + std::string serve_dir, shm_dir; // closed-loop 서버 (--serve) + int width = 0, height = 0, autoshot = 0, frame_lo = 0, frame_hi = -1; + // 설정 파일 (configs/sim.json). 명령줄 옵션이 덮어쓰므로 먼저 읽는다. + std::string config_path = VKGS_CONFIG_DEFAULT, sky_mode; + bool keep_sky_gaussians = false; // 학습기와 픽셀 비교 때 (nusim render compare) + for (int i = 2; i + 1 < argc; ++i) + if (std::string(argv[i]) == "--config") config_path = argv[i + 1]; + if (!config_path.empty() && std::filesystem::exists(config_path)) load_sim_config(config_path); + else if (config_path != VKGS_CONFIG_DEFAULT) throw std::runtime_error("설정 파일이 없음: " + config_path); + // --reflect OUT.json: 모든 셰이더의 인터페이스(리플렉션)를 JSON 으로 쓰고 끝낸다 — 렌더 그래프·문서·도구용 + for (int i = 2; i + 1 < argc; ++i) + if (std::string(argv[i]) == "--reflect") { + nlohmann::json out = nlohmann::json::array(); + std::vector files; + for (const auto& e : std::filesystem::directory_iterator(VKGS_SPIRV_DIR)) + if (e.path().extension() == ".spv") files.push_back(e.path()); + std::sort(files.begin(), files.end()); + for (const auto& f : files) { + auto si = vkgs::reflect_spirv(read_file(f.string()), f.stem().string()); + nlohmann::json j{{"name", si.name}, {"stage", vkgs::stage_name(si.stage)}, {"push_size", si.push_size}}; + for (const auto& m : si.push_members) j["push"].push_back({{"name", m.name}, {"offset", m.offset}, {"size", m.size}}); + for (const auto& b : si.bindings) + j["bindings"].push_back({{"set", b.set}, {"binding", b.binding}, + {"type", vkgs::descriptor_type_name(b.type)}, {"name", b.name}}); + if (!si.outputs.empty()) j["outputs"] = si.outputs; + if (si.stage == VK_SHADER_STAGE_COMPUTE_BIT) j["local_size"] = {si.local_size[0], si.local_size[1], si.local_size[2]}; + out.push_back(j); + } + std::ofstream(argv[i + 1]) << out.dump(2) << "\n"; + std::cout << "셰이더 " << files.size() << "개 리플렉션 → " << argv[i + 1] << "\n"; + return 0; + } + // --graph OUT.dot: 렌더 그래프(configs/render_graph.json)를 위상 정렬해 Graphviz 로 쓰고 끝낸다 (GPU 불필요). + // 같은 이름의 .json 에는 실행 순서와 리소스 사용을 쓴다. + for (int i = 2; i + 1 < argc; ++i) + if (std::string(argv[i]) == "--graph") { + vkgs::RenderGraph g; + std::ifstream f(g_cfg.render_graph); + if (!f) throw std::runtime_error("렌더 그래프 파일이 없음: " + g_cfg.render_graph); + g.load(nlohmann::json::parse(f, nullptr, true, true)); + std::filesystem::path out = argv[i + 1]; + std::ofstream(out) << g.to_dot(); + std::ofstream(std::filesystem::path(out).replace_extension(".json")) << g.to_json().dump(2) << "\n"; + std::cout << "렌더 그래프 (패스 " << g.order().size() << "개) → " << out.string() << "\n"; + return 0; + } + WindowInit winit; + winit.max_speed_kmh = g_cfg.max_speed_kmh; + winit.curb = g_cfg.curb; + winit.front_cams = g_cfg.front_cams; + winit.bev_pip = g_cfg.bev_pip; + winit.graph_window = g_cfg.graph_window; + sky_mode = g_cfg.sky_mode; + for (int i = 2; i < argc; ++i) { + std::string a = argv[i]; + auto next = [&]() -> std::string { + if (i + 1 >= argc) throw std::runtime_error(a + " 뒤에 값이 필요함"); + return argv[++i]; + }; + if (a == "--cams") cams = next(); + else if (a == "--voxels") voxels_path = next(); + else if (a == "--segments") segments_path = next(); + else if (a == "--lines") lines = next(); + else if (a == "--offscreen") offscreen = true; + else if (a == "--serve") serve_dir = next(), offscreen = true; // 서버는 창을 띄우지 않는다 + else if (a == "--shm") shm_dir = next(); + else if (a == "--out") out = next(); + else if (a == "--only") only = next(); + else if (a == "--shot-dir") shot_dir = next(); + else if (a == "--autoshot") autoshot = std::stoi(next()); + else if (a == "--mode") winit.mode = next(); + else if (a == "--cam") winit.cam = next(); + else if (a == "--frame") winit.frame = std::stoi(next()); + else if (a == "--bev-height") winit.bev_height = std::stof(next()); + else if (a == "--play") winit.play = true; + else if (a == "--clip") winit.clip = std::stof(next()); + else if (a == "--bev") bev = true; + else if (a == "--drivable") drivable_path = next(); + else if (a == "--obstacles") obstacles_path = next(); + else if (a == "--no-curb") winit.curb = false; + else if (a == "--weather") weather_spec = next(); + else if (a == "--camera-mode") camera_mode_spec = next(); + else if (a == "--drive") winit.drive = true; + else if (a == "--cam-view") winit.chase = false; + else if (a == "--controls") winit.controls = next(); + else if (a == "--record") winit.record = next(); + else if (a == "--record-screen") winit.record_screen = next(); + else if (a == "--autopilot") winit.autopilot = true; + else if (a == "--show-sensors") winit.show_sensors = true; + else if (a == "--hide-panels") winit.hide_panels = true; + else if (a == "--max-speed") winit.max_speed_kmh = std::stof(next()); + else if (a == "--ego-asset") ego_asset = next(); + else if (a == "--ibl") ibl_dir = next(); + else if (a == "--gltf") gltf_path = next(); + else if (a == "--ego-gltf") ego_gltf_path = next(); + else if (a == "--ego-length") ego_length = std::stof(next()); + else if (a == "--sky") sky_path = next(); + else if (a == "--sky-mode") sky_mode = next(); + else if (a == "--keep-sky-gaussians") keep_sky_gaussians = true; + else if (a == "--front-cams") winit.front_cams = true; + else if (a == "--bev-pip") winit.bev_pip = true; + else if (a == "--objects") objects_dir = next(); + else if (a == "--pass") { // 렌더 그래프 패스 켜기·끄기: 이름=on|off (설정 passes 를 덮어쓴다) + std::string v = next(); + auto eq = v.find('='); + if (eq == std::string::npos) throw std::runtime_error("--pass 이름=on|off"); + g_cfg.passes[v.substr(0, eq)]["enabled"] = v.substr(eq + 1) == "on"; + } + else if (a == "--config" || a == "--reflect" || a == "--graph") next(); // 위에서 이미 처리했다 + else if (a == "--exposure") exposure = std::stof(next()); + else if (a == "--pbr-spheres") pbr_spheres = true; + else if (a == "--no-env") no_env = true; + else if (a == "--env-lod") env_lod = std::stof(next()); + else if (a == "--frames") { // A:B (B 미포함) 또는 A + std::string s = next(); + auto c = s.find(':'); + frame_lo = std::stoi(s.substr(0, c)); + frame_hi = c == std::string::npos ? frame_lo + 1 : std::stoi(s.substr(c + 1)); + } + else if (a == "--size") { + std::string s = next(); + auto x = s.find('x'); + width = std::stoi(s.substr(0, x)); + height = std::stoi(s.substr(x + 1)); + } else if (a == "-h" || a == "--help") { + usage(); + return 0; + } else { + std::cerr << "알 수 없는 옵션: " << a << "\n"; + return 1; + } + } + + try { + auto t0 = std::chrono::steady_clock::now(); + stbi_write_png_compression_level = g_cfg.png_compression; // 인코딩 스레드가 읽기만 한다 + stbi_write_force_png_filter = g_cfg.png_filter; // -1 = 행마다 5종 시험 (느림) + vkgs::Scene scene = vkgs::load_ply(ply); + // 하늘 가우시안 (nusim recon skylabel 이 표시, ply 옆 sky_gaussians.u8): 시뮬레이션 하늘 모드에서는 뺀다 — + // 하늘을 분리하지 않고 학습한 모델의 낮 하늘이 밤·흐림에도 하얗게 남지 않게. 그 자리에 sky_sim 하늘이 보인다. + if (sky_mode == "sim" && !keep_sky_gaussians) { + std::filesystem::path mp = std::filesystem::path(ply).parent_path() / "sky_gaussians.u8"; + if (std::filesystem::exists(mp)) { + std::vector m = read_file(mp.string()); + if (m.size() != scene.gaussians.size()) + std::cerr << " sky_gaussians.u8 크기가 ply 와 다름 — 무시 (nusim recon skylabel 로 다시)\n"; + else { + size_t w = 0; + for (size_t i = 0; i < m.size(); ++i) + if (!m[i]) scene.gaussians[w++] = scene.gaussians[i]; + std::cout << "하늘 가우시안 " << (m.size() - w) << "개 뺌 (시뮬레이션 하늘)\n"; + scene.gaussians.resize(w); + } + } + } + double load_s = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + std::cout << "ply " << ply << "\n 가우시안 " << scene.gaussians.size() << "개, SH " << scene.sh_degree + << "차, 원점 (" << std::fixed << scene.origin[0] << ", " << scene.origin[1] << ", " + << scene.origin[2] << "), 로드 " << load_s << "초\n"; + + Rig rig; + if (!cams.empty()) { + rig = load_rig(cams); + std::cout << "카메라 " << rig.views.size() << "뷰 / " << rig.n_frames << "프레임 / 자차 포즈 " + << rig.ego.size() << "개\n"; + } + if (width == 0) { + width = rig.views.empty() ? 1600 : rig.views.front().width; + height = rig.views.empty() ? 900 : rig.views.front().height; + } + + Renderer r(!offscreen, width, height); + // 창 모드는 설정의 시작 날씨(기본 '맑음')로 — 오프스크린·학습기 비교는 날씨 없이 그대로 + if (weather_spec.empty() && !offscreen) weather_spec = g_cfg.weather_start; + if (!weather_spec.empty()) r.weather = parse_weather(weather_spec); + if (!camera_mode_spec.empty()) { + static const char* names[4] = {"rgb", "depth", "semantic", "gray"}; + auto it = std::find(names, names + 4, camera_mode_spec); + if (it == names + 4) throw std::runtime_error("--camera-mode 는 rgb|depth|semantic|gray"); + r.camera_mode = int(it - names); + } + std::cout << "장치 " << r.device_name() << ", 렌더 " << width << "x" << height << "\n"; + vkgs::Scene asset; + if (!ego_asset.empty() && !ego_gltf_path.empty()) { + std::cout << "자차: --ego-gltf 가 있어 3DGS 자차 에셋(--ego-asset)은 쓰지 않음\n"; + ego_asset.clear(); + } + if (!ego_asset.empty()) { + asset = vkgs::load_ply(ego_asset); + if (asset.sh_degree != scene.sh_degree) + throw std::runtime_error("에셋 SH 차수가 씬과 다름 (" + std::to_string(asset.sh_degree) + " vs " + + std::to_string(scene.sh_degree) + ")"); + std::cout << "자차 에셋 " << ego_asset << ", 가우시안 " << asset.gaussians.size() << "개\n"; + } + if (!objects_dir.empty()) r.set_object_capacity(g_cfg.obj_max_gaussians, g_cfg.obj_max_instances); + static SegmentStreamer streamer; + if (!segments_path.empty()) { + streamer.map = vkgs::SegmentMap::load(segments_path); + r.set_scene_capacity(uint32_t(streamer.map.max_gaussians())); + if (streamer.map.segs.front().ply != ply) + throw std::runtime_error("--segments: ply 인자는 구간 목록의 첫 구간이어야 함 (" + streamer.map.segs.front().ply + ")"); + std::cout << "구간 모델 " << streamer.map.segs.size() << "개 (최대 가우시안 " << streamer.map.max_gaussians() + << "개) — 자차 위치로 바꿔 그림\n"; + } + r.upload(scene, ego_asset.empty() ? nullptr : &asset); + static vkgs::ObjectWorld world; // 창 루프·오프스크린이 같이 쓴다 + if (!objects_dir.empty()) { + world.load(objects_dir, scene.origin); + std::vector og; + std::vector oid; + world.build_gpu(og, oid); + r.set_scene_objects(og, oid); + std::cout << "장면 객체 " << objects_dir << ": " << world.objects.size() << "개, 에셋 " << world.assets.size() + << "개, 가우시안 " << og.size() << "개\n"; + } + if (!ibl_dir.empty()) { + r.load_ibl(ibl_dir); + r.show_env = !no_env; + r.env_lod = env_lod; + } + if (!sky_path.empty()) r.load_sky(sky_path); // IBL 뒤에 (irradiance/LUT 를 같이 묶을 수 있게) + if (sky_mode == "sim") r.sky_mode_ = Renderer::SkyMode::Sim; + else if (sky_mode == "learned") r.sky_mode_ = Renderer::SkyMode::Learned; + else if (sky_mode == "ibl") r.sky_mode_ = Renderer::SkyMode::Ibl; + else throw std::runtime_error("--sky-mode 는 sim | learned | ibl: " + sky_mode); + vkgs::GltfModel gltf_model, ego_gltf_model; + GltfSet gltf_set; + r.exposure = exposure; + if (const char* e = std::getenv("VKGS_SHADOW")) r.shadow_opacity = std::stof(e); // 접지 그림자 세기 (0 = 끔) + if (!ego_gltf_path.empty()) { // 자차를 glTF 메시로 (3DGS 자차 에셋보다 우선) + ego_gltf_model = vkgs::load_gltf(ego_gltf_path); + g_ego = ego_geom_from_model(ego_gltf_model, ego_length); + gltf_set.ego = &ego_gltf_model; + gltf_set.ego_id = r.add_gltf(ego_gltf_model); + r.ego_model = gltf_set.ego_id; + std::printf("자차 glTF %s (길이 %.2f m 로 맞춤, 축거 %.2f m, 뒷바퀴 축 → 차체 %.2f ~ %.2f m, 폭 %.2f m)\n", + ego_gltf_path.c_str(), g_ego.length, g_ego.geom.wheelbase, g_ego.geom.rear, g_ego.geom.front, + 2 * g_ego.geom.half_width); + int nf = 0; + for (const auto& w : ego_gltf_model.wheels) nf += w.front; + std::cout << " 바퀴 " << ego_gltf_model.wheels.size() << "개 (조향 " << nf << "개) — 구름·조향·서스펜션 애니메이션" + << (ego_gltf_model.wheels.empty() ? " 없음 (Wheel 노드를 못 찾음)" : "") << "\n"; + } + if (!gltf_path.empty()) { + gltf_model = vkgs::load_gltf(gltf_path); + gltf_set.other = &gltf_model; + gltf_set.other_id = r.add_gltf(gltf_model); + size_t n = 0; + for (auto& kv : rig.agents) n = std::max(n, kv.second.size()); + std::cout << "다른 차량 프레임당 최대 " << n << "대 (" << rig.agents.size() << "프레임)\n"; + } + if (pbr_spheres) { + // 시작 프레임 자차 앞 6 m, 왼쪽→오른쪽 거칠기 0→1, 아래 줄 비금속(흰색) / 위 줄 금속(은) + const EgoPose* e0 = nullptr; + for (const auto& e : rig.ego) + if (e.frame == std::max(frame_lo, winit.frame)) e0 = &e; + if (!e0 && !rig.ego.empty()) e0 = &rig.ego.front(); + Vec3 p0 = e0 ? e0->pos : Vec3{}; + float yaw = e0 ? e0->yaw : 0.0f; + Vec3 fwd{std::cos(yaw), std::sin(yaw), 0}, left{-std::sin(yaw), std::cos(yaw), 0}; + std::vector sp; + for (int row = 0; row < 2; ++row) + for (int k = 0; k < 5; ++k) { + Sphere s; + s.radius = 0.42f; + s.center = p0 + fwd * 7.5f + left * (2.2f - 1.1f * k) + Vec3{0, 0, 1.0f + 1.0f * row}; + s.roughness = k / 4.0f; + s.metallic = float(row); + float a = row ? 0.95f : 0.85f; + s.albedo[0] = s.albedo[1] = s.albedo[2] = a; + sp.push_back(s); + } + r.set_spheres(sp); + } + if (!lines.empty()) { + auto lv = load_lines(lines); + r.set_static_lines(lv); + std::cout << "맵 선 " << lv.size() / 2 << "개 선분\n"; + } + + vkgs::Drivable drivable; + if (!drivable_path.empty()) drivable.load(drivable_path); + if (!voxels_path.empty()) { + g_voxels.load(voxels_path); + std::printf("복셀 월드 %s (%dx%dx%d, %.2f m) — 라이다 %zu대 · 레이더 %zu대\n", voxels_path.c_str(), g_voxels.n[0], + g_voxels.n[1], g_voxels.n[2], g_voxels.res, g_cfg.lidars.size(), g_cfg.radars.size()); + } + vkgs::ObstacleGrid obst; + if (!obstacles_path.empty()) { + obst.load(obstacles_path); + std::printf("장애물 지도 %s (%zu칸)%s\n", obstacles_path.c_str(), obst.cells(), + winit.curb ? ", 연석 충돌 켬" : ", 연석 충돌 끔"); + } + if (!serve_dir.empty()) { + ServeOptions so; + so.dir = serve_dir; + so.shm = shm_dir.empty() ? "/dev/shm/vkgs_" + std::to_string(getpid()) : shm_dir; + so.max_speed_kmh = winit.max_speed_kmh; + so.curb = winit.curb; + return run_server(r, rig, drivable, obst, segments_path.empty() ? nullptr : &streamer, so); + } + if (!offscreen) + return run_window(r, rig, shot_dir, autoshot, winit, drivable, obst, gltf_set, + objects_dir.empty() ? nullptr : &world, segments_path.empty() ? nullptr : &streamer); + std::vector obj_table; + auto objects_at = [&](int frame, int64_t fallback_ts) { // 오프스크린: 그 프레임의 자차 시각으로 (6캠이 같은 시각) + if (objects_dir.empty()) return; + int64_t ts = fallback_ts; + for (const auto& e : rig.ego) + if (e.frame == frame) { ts = e.ts; break; } + world.table_at(ts, obj_table); + r.set_object_table(obj_table); + }; + + if (rig.views.empty()) throw std::runtime_error("--offscreen 에는 --cams 가 필요함"); + fs::create_directories(out); + Timing sum; + std::map pass_sum; + int n = 0; + auto in_range = [&](int fr) { return fr >= frame_lo && (frame_hi < 0 || fr < frame_hi); }; + // 날씨 시계 = 그 프레임의 자차 포즈 시각 (6캠이 같은 값 — 자차 속도·빗줄기 기울기가 실제 주행과 맞게). + // 자차 포즈가 없는 카메라 파일이면 예전처럼 프레임 × 0.05 s. + const int64_t ts0 = rig.ego.empty() ? 0 : rig.ego.front().ts; + auto clock_of = [&](int frame) { + for (const auto& e : rig.ego) + if (e.frame == frame) return float(double(e.ts - ts0) * 1e-6); + return 0.05f * float(frame); + }; + auto seg_at = [&](int frame) { // 구간 모델: 그 프레임 자차 위치의 구간으로 (다음 구간은 미리 읽는다) + if (segments_path.empty()) return; + for (const auto& e : rig.ego) + if (e.frame == frame) { streamer.update(r, e.pos.x, e.pos.y, true); return; } + }; + if (bev) { // 프레임마다 BEV 한 장 (자차 포즈가 있는 프레임만) + for (const auto& e : rig.ego) { + if (!in_range(e.frame)) continue; + seg_at(e.frame); + std::vector dyn; + if (!r.has_obj() && r.ego_model < 0) ego_box_lines(e, dyn); + r.set_dynamic_lines(dyn); + { + auto ag = rig.agents.find(e.frame); + r.set_vehicles(vehicle_draws(gltf_set, ag == rig.agents.end() ? nullptr : &ag->second, &e)); + r.set_shadows(vehicle_shadows(gltf_set, ag == rig.agents.end() ? nullptr : &ag->second, &e)); + } + set_ego_light(r, e.pos.x, e.pos.y, e.pos.z, e.yaw); + r.weather_time = clock_of(e.frame); + objects_at(e.frame, e.ts); + RenderCam rc = bev_camera(&e, e.pos, e.yaw, winit.bev_height, winit.clip, width, height); + rc.obj_xform = ego_asset_xform(e.pos.x, e.pos.y, e.pos.z, e.yaw); + rc.show_obj = true; + char name[256]; + std::snprintf(name, sizeof(name), "%s/f%04d_BEV.png", out.c_str(), e.frame); + r.render(rc, name, false); + } + r.set_dynamic_lines({}); + } + for (size_t i = 0; i < rig.views.size(); ++i) { + const CamView& v = rig.views[i]; + if (!only.empty() && v.name != only) continue; + if (!in_range(v.frame)) continue; + seg_at(v.frame); + RenderCam rc; + rc.w2c = v.w2c; + float sx = float(width) / v.width, sy = float(height) / v.height; + rc.fx = v.fx * sx; rc.fy = v.fy * sy; rc.cx = v.cx * sx; rc.cy = v.cy * sy; + rc.sensor = true; // 오프스크린 6캠 = 카메라 센서 (--camera-mode 가 여기에) + char name[256]; + std::snprintf(name, sizeof(name), "%s/f%04d_%s.png", out.c_str(), v.frame, v.name.c_str()); + { + auto ag = rig.agents.find(v.frame); + r.set_vehicles(vehicle_draws(gltf_set, ag == rig.agents.end() ? nullptr : &ag->second)); + r.set_shadows(vehicle_shadows(gltf_set, ag == rig.agents.end() ? nullptr : &ag->second)); + } + for (const auto& e : rig.ego) + if (e.frame == v.frame) set_ego_light(r, e.pos.x, e.pos.y, e.pos.z, e.yaw); + r.weather_time = clock_of(v.frame); + objects_at(v.frame, v.ts); + Timing t = r.render(rc, name, false); + FrameMark; // Tracy: 오프스크린은 이미지 한 장이 한 프레임 + TracyPlot("visible gaussians", int64_t(t.visible)); + if (n > 0) { // 첫 프레임은 파이프라인 워밍업이라 뺀다 + for (const auto* p : r.executed()) pass_sum[p->name] += p->gpu_ms; + sum.preprocess_ms += t.preprocess_ms; + sum.sort_ms += t.sort_ms; + sum.raster_ms += t.raster_ms; + } + sum.visible += t.visible; + ++n; + } + r.flush_png(); + int m = std::max(n - 1, 1); + std::printf("렌더 %d장 → %s\n평균 전처리 %.2f ms + 정렬 %.2f ms + 래스터 %.2f ms = %.2f ms/장, 보이는 가우시안 %u개\n", + n, out.c_str(), sum.preprocess_ms / m, sum.sort_ms / m, sum.raster_ms / m, + (sum.preprocess_ms + sum.sort_ms + sum.raster_ms) / m, sum.visible / std::max(n, 1)); + // 렌더 그래프 패스별 평균 GPU 시간 (실행 순서) + std::printf("패스별 GPU ms:"); + for (int i : r.graph().order()) { + const auto& p = r.graph().passes()[i]; + if (pass_sum.count(p.name)) std::printf(" %s %.3f", p.name.c_str(), pass_sum[p.name] / m); + } + std::printf("\n"); + } catch (const std::exception& e) { + std::cerr << "\033[1;31m오류:\033[0m " << e.what() << "\n"; + return 1; + } + return 0; +} diff --git a/src/vkgs/src/voxcarve.cpp b/src/vkgs/src/voxcarve.cpp new file mode 100644 index 0000000000000000000000000000000000000000..6d7c27f0bd73293eec5f1acdd2e49f402569465d --- /dev/null +++ b/src/vkgs/src/voxcarve.cpp @@ -0,0 +1,107 @@ +// vkgs_voxcarve — 복셀 월드의 빈 공간 깎기 (nusim/sim/voxels.py 가 부른다). +// +// 누적 라이다에는 '그 순간에만 있던 것' 이 남는다: 움직이는 차는 3D 박스로 지우지만, sweep 마다 박스가 조금씩 어긋나 +// 범퍼·옆면 조각이 차선을 따라 지면 위 0.3~1 m 에 쌓였다. 시뮬 라이다가 그 조각에 먼저 부딪혀 실제보다 1 m 넘게 짧은 +// 거리를 냈다 (scene-0402, 2026-10-09). 실제 라이다의 빔은 매 sweep 그 자리를 '지나간다' — 빔이 지나간 횟수(pass)와 +// 부딪힌 횟수(hit)를 칸마다 세면, 늘 그 자리에 있는 것(벽·기둥·나무)과 잠깐 있던 것이 갈린다 (OctoMap 류 광선 깎기). +// +// 입력 (바이너리, 리틀 엔디안): +// int32 nx ny nz, float32 res, float64 lo[3] +// uint64 K, uint64 keys[K] (오름차순 — 후보 칸 번호 (iy*nx+ix)*nz+iz) +// uint64 S, float64 origins[S][3] (sweep 마다 라이다 위치, 씬 좌표) +// uint64 N, (uint32 sweep, float32 x y z)[N] (광선 끝점, 씬 좌표) +// 출력: uint32 passes[K] — 그 칸을 빔이 지나간 횟수 (끝점 칸과 끝점 앞 end_margin 안은 세지 않는다) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char** argv) { + if (argc < 3) { + std::fprintf(stderr, "사용법: vkgs_voxcarve <입력> <출력> [끝 여유 m (기본 0.4)]\n"); + return 2; + } + float end_margin = argc > 3 ? std::stof(argv[3]) : 0.4f; + std::ifstream f(argv[1], std::ios::binary); + auto rd = [&](void* p, size_t n) { + f.read(reinterpret_cast(p), std::streamsize(n)); + if (!f) throw std::runtime_error("입력이 잘림"); + }; + int32_t n[3]; + float res; + double lo[3]; + rd(n, 12), rd(&res, 4), rd(lo, 24); + uint64_t K, S, N; + rd(&K, 8); + std::vector keys(K); + rd(keys.data(), K * 8); + rd(&S, 8); + std::vector org(S * 3); + rd(org.data(), S * 24); + rd(&N, 8); + struct Ray { uint32_t s; float p[3]; }; + std::vector rays(N); + rd(rays.data(), N * sizeof(Ray)); + static_assert(sizeof(Ray) == 16); + // 후보 칸 비트 (빠른 거르기) + 정렬된 키에서 이분 탐색 + size_t total = size_t(n[0]) * n[1] * n[2]; + std::vector bits((total + 7) / 8, 0); + for (uint64_t k : keys) bits[k >> 3] |= uint8_t(1u << (k & 7)); + std::vector> passes(K); + for (auto& p : passes) p.store(0); + int T = std::max(1u, std::thread::hardware_concurrency()); + auto work = [&](int tid) { + for (size_t r = tid; r < N; r += T) { + const Ray& ry = rays[r]; + double o[3] = {org[ry.s * 3] - lo[0], org[ry.s * 3 + 1] - lo[1], org[ry.s * 3 + 2] - lo[2]}; + double e[3] = {ry.p[0] - lo[0], ry.p[1] - lo[1], ry.p[2] - lo[2]}; + double d[3] = {e[0] - o[0], e[1] - o[1], e[2] - o[2]}; + double len = std::sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]); + if (len < 1e-3) continue; + for (double& x : d) x /= len; + double tend = len - end_margin; + if (tend <= 0) continue; + int i[3], step[3]; + double tmax[3], tdelta[3]; + bool inside = true; + for (int a = 0; a < 3; ++a) { + i[a] = int(std::floor(o[a] / res)); + step[a] = d[a] > 0 ? 1 : -1; + double edge = (i[a] + (d[a] > 0 ? 1 : 0)) * double(res); + tmax[a] = std::abs(d[a]) < 1e-12 ? 1e30 : (edge - o[a]) / d[a]; + tdelta[a] = std::abs(d[a]) < 1e-12 ? 1e30 : res / std::abs(d[a]); + } + double t = 0; + while (t < tend) { + inside = i[0] >= 0 && i[1] >= 0 && i[2] >= 0 && i[0] < n[0] && i[1] < n[1] && i[2] < n[2]; + if (inside) { + uint64_t k = (uint64_t(i[1]) * n[0] + i[0]) * n[2] + i[2]; + if ((bits[k >> 3] >> (k & 7)) & 1) { + auto it = std::lower_bound(keys.begin(), keys.end(), k); + passes[size_t(it - keys.begin())].fetch_add(1, std::memory_order_relaxed); + } + } + int a = tmax[0] < tmax[1] ? (tmax[0] < tmax[2] ? 0 : 2) : (tmax[1] < tmax[2] ? 1 : 2); + t = tmax[a]; + tmax[a] += tdelta[a]; + i[a] += step[a]; + } + } + }; + std::vector ts; + for (int k = 1; k < T; ++k) ts.emplace_back(work, k); + work(0); + for (auto& t : ts) t.join(); + std::vector out(K); + for (size_t k = 0; k < K; ++k) out[k] = passes[k].load(); + std::ofstream g(argv[2], std::ios::binary); + g.write(reinterpret_cast(out.data()), std::streamsize(K * 4)); + std::printf("광선 %llu개, 후보 칸 %llu개, 스레드 %d\n", (unsigned long long)N, (unsigned long long)K, T); + return 0; +}