diff --git a/docs/usr/modules/graphics.md b/docs/usr/modules/graphics.md index 549c10d1..e92fe906 100644 --- a/docs/usr/modules/graphics.md +++ b/docs/usr/modules/graphics.md @@ -295,11 +295,15 @@ WebGPU 使用带 origin 的 `WriteTexture`;两者都不重建 Texture、采样 **源码:** [`src/modules/graphics/`](../../../src/modules/graphics/) **相关测试:** 在 [`test/`](../../../test/) 中搜索 `graphics`、`Camera2D`、`Camera3D`。 -### HDR 与反射探针绑定 +### HDR、景深与反射探针绑定 -下列绑定用于 HDR 图像、反射探针采集/注册、天空环境、曝光、Bloom 与反射链质量控制: +下列绑定用于 HDR 图像、反射探针采集/注册、天空环境、曝光、Bloom、Gaussian 景深(DOF)与反射链质量控制。 +`Camera3D.setDepthOfField(focusDistance, maxBlurPx, focusRange)` 配置最终 HDR resolve 上的可分离 Gaussian 景深; +`maxBlurPx <= 0` 关闭效果。焦点平面用视空间距离表示;`getDofFocusDistance` / `getDofMaxBlur` / +`getDofFocusRange` 读取当前参数。HD-2D 缩微感可配合 `hd2d.Hd2dLook` 一键写入(见 [HD2D](hd2d.md))。 +Cutout/masked 精灵需写入深度,DOF 才能正确对焦角色。 -`applyConfiguredToCamera`、`applyToCamera`、`clearReflectionProbe`、`configureInfluence`、`filterAndPublish`、`getActiveCubemap`、`getAdaptiveFaceBudget`、`getAdaptiveFilterSamples`、`getBloomIntensity`、`getBloomThreshold`、`getCaptureClusteredLighting`、`getCaptureFarDistance`、`getCaptureLodDistanceScale`、`getCaptureMask`、`getCaptureTransparent`、`getCenterX`、`getCenterY`、`getCenterZ`、`getCount`、`getEnvProbeCenterX`、`getEnvProbeCenterY`、`getEnvProbeCenterZ`、`getEnvProbeExtentX`、`getEnvProbeExtentY`、`getEnvProbeExtentZ`、`getEnvironmentLighting`、`getEnvironmentLightingIntensity`、`getExposure`、`getFaceCanvas`、`getGpuBudgetMs`、`getInfluenceBlendDistance`、`getInfluenceExtentX`、`getInfluenceExtentY`、`getInfluenceExtentZ`、`getInfluenceIntensity`、`getInfluencePriority`、`getLastCandidateCount`、`getLastCapturedFaceCount`、`getLastFilterSampleCount`、`getLastPublishedCount`、`getLastSelectedCount`、`getMaxRoughness`、`getPendingFaceCount`、`getPostProcessQuality`、`getPublishedRevision`、`getReflectionCaptureEnabled`、`getReflectionCaptureMask`、`getReflectionProbeCount`、`getReflectionQuality`、`getRefreshInterval`、`getResolution`、`getResolutionScale`、`getRevision`、`getSelectionHysteresis`、`getSkyB`、`getSkyFaceColor`、`getSkyFaceTexture`、`getSkyFaceTextureScale`、`getSkyG`、`getSkyIntensity`、`getSkyR`、`getSmoothedGpuDurationMs`、`getStagedRevision`、`getStagingCubemap`、`getThickness`、`getTotalCapturedFaceCount`、`getUpdateMode`、`hasEnvProbe`、`isAutoExposure`、`isCaptureComplete`、`isCapturePending`、`isRecaptureQueued`、`newHDRImageData`、`newReflectionProbeCapture`、`newReflectionProbeRegistry`、`queueCapture`、`queueCaptureAABB`、`remove`、`reportGpuDurationMs`、`requestCapture`、`setAutoExposure`、`setBloom`、`setCaptureClusteredLighting`、`setCaptureLodDistanceScale`、`setCaptureMask`、`setCaptureTransparent`、`setEnvironmentLighting`、`setExposure`、`setGpuBudgetMs`、`setMaxRoughness`、`setReflectionCaptureEnabled`、`setReflectionCaptureMask`、`setReflectionProbe`、`setReflectionQuality`、`setRefreshInterval`、`setResolutionScale`、`setSelectionHysteresis`、`setSkyColor`、`setSkyFaceColor`、`setSkyFaceTexture`、`setSkyFaceTextureScale`、`setUpdateMode`、`stageCapturedFaces`、`tick`、`tickAdaptive`、`updateCamera`。 +`applyConfiguredToCamera`、`applyToCamera`、`clearReflectionProbe`、`configureInfluence`、`filterAndPublish`、`getActiveCubemap`、`getAdaptiveFaceBudget`、`getAdaptiveFilterSamples`、`getBloomIntensity`、`getBloomThreshold`、`getCaptureClusteredLighting`、`getCaptureFarDistance`、`getCaptureLodDistanceScale`、`getCaptureMask`、`getCaptureTransparent`、`getCenterX`、`getCenterY`、`getCenterZ`、`getCount`、`getDofFocusDistance`、`getDofFocusRange`、`getDofMaxBlur`、`getEnvProbeCenterX`、`getEnvProbeCenterY`、`getEnvProbeCenterZ`、`getEnvProbeExtentX`、`getEnvProbeExtentY`、`getEnvProbeExtentZ`、`getEnvironmentLighting`、`getEnvironmentLightingIntensity`、`getExposure`、`getFaceCanvas`、`getGpuBudgetMs`、`getInfluenceBlendDistance`、`getInfluenceExtentX`、`getInfluenceExtentY`、`getInfluenceExtentZ`、`getInfluenceIntensity`、`getInfluencePriority`、`getLastCandidateCount`、`getLastCapturedFaceCount`、`getLastFilterSampleCount`、`getLastPublishedCount`、`getLastSelectedCount`、`getMaxRoughness`、`getPendingFaceCount`、`getPostProcessQuality`、`getPublishedRevision`、`getReflectionCaptureEnabled`、`getReflectionCaptureMask`、`getReflectionProbeCount`、`getReflectionQuality`、`getRefreshInterval`、`getResolution`、`getResolutionScale`、`getRevision`、`getSelectionHysteresis`、`getSkyB`、`getSkyFaceColor`、`getSkyFaceTexture`、`getSkyFaceTextureScale`、`getSkyG`、`getSkyIntensity`、`getSkyR`、`getSmoothedGpuDurationMs`、`getStagedRevision`、`getStagingCubemap`、`getThickness`、`getTotalCapturedFaceCount`、`getUpdateMode`、`hasEnvProbe`、`isAutoExposure`、`isCaptureComplete`、`isCapturePending`、`isRecaptureQueued`、`newHDRImageData`、`newReflectionProbeCapture`、`newReflectionProbeRegistry`、`queueCapture`、`queueCaptureAABB`、`remove`、`reportGpuDurationMs`、`requestCapture`、`setAutoExposure`、`setBloom`、`setCaptureClusteredLighting`、`setCaptureLodDistanceScale`、`setCaptureMask`、`setCaptureTransparent`、`setDepthOfField`、`setEnvironmentLighting`、`setExposure`、`setGpuBudgetMs`、`setMaxRoughness`、`setReflectionCaptureEnabled`、`setReflectionCaptureMask`、`setReflectionProbe`、`setReflectionQuality`、`setRefreshInterval`、`setResolutionScale`、`setSelectionHysteresis`、`setSkyColor`、`setSkyFaceColor`、`setSkyFaceTexture`、`setSkyFaceTextureScale`、`setUpdateMode`、`stageCapturedFaces`、`tick`、`tickAdaptive`、`updateCamera`。 ### 预编译网格 Shader 与画布快照 diff --git a/docs/usr/modules/hd2d.md b/docs/usr/modules/hd2d.md index b8f317f9..857c8f30 100644 --- a/docs/usr/modules/hd2d.md +++ b/docs/usr/modules/hd2d.md @@ -4,6 +4,7 @@ HD-2D 模块把 2D 内容叠加到现有 3D 渲染管线,实现「2D tilemap - `TileMap3D`:把 2D tilemap(`map` 模块的 `TileLayer`)挤成 3D 地形网格。 - `Sprite3D`:把 2D 精灵图/角色帧作为始终正对相机的 billboard 渲染进 3D 场景,支持精灵表帧动画。 +- `Hd2dLook`:参考 [HD2DURP](https://github.com/dulong-lab/HD2DURP) 的景深(DopFix)+ Bloom + 像素采样组合,一键写到 `Camera3D`。 模块绑定在 `eve.Hd2D`,脚本槽位为 `hd2d`。 @@ -47,6 +48,11 @@ local scale = tile.getHeightScale(); // 读取当前高度缩放 Pivot 使用可见图像左上角为 (0,0)、右下角为 (1,1),旋转、缩放及 UV 翻转不改变世界锚点。 相机必须在解除绑定前保持有效;退化相机基向量保留最后一次有效朝向。 +精灵默认使用 **masked cutout** 材质并开启深度写入(对齐 HD2DURP 的 DopFix / +`TransparentCutout`):透明像素丢弃,不透明像素写入深度,景深才能正确对焦角色。 +可用 `setAlphaCutoff` / `setDepthWrite` / `setDoubleSided` 调整; +`setBillboardMode("yaw")` 切换为仅绕 Y 轴的圆柱 billboard。 + ```squirrel local hero = hd2d.newSprite(gfx); hero.setTexture(gfx.newTextureFromFile("assets/hero.png")); @@ -55,6 +61,8 @@ hero.setSize(28.0, 56.0); // billboard 尺寸 hero.setTint(1.0, 1.0, 1.0, 1.0); // 颜色倍率(alpha 参与裁切) hero.setVisible(true); hero.setCamera(cam); // 绑定相机后 update() 每帧转向相机 +hero.setAlphaCutoff(0.5); +hero.setDepthWrite(true); gfx.setTextureSampler(hero.getTexture(), "nearest", "none", 1.0, 0.0); // 清晰像素采样 local tx = hero.getPositionX(); local ty = hero.getPositionY(); local tz = hero.getPositionZ(); @@ -100,8 +108,27 @@ Sprite、Camera 与纹理的调用在渲染线程进行,Graphics 及相关 ECS 固定步长、无随机数,足底锚点由图集透明边距确定。示例验收说明和素材 生成记录见该示例的 README。 +## Hd2dLook:景深 + Bloom + 像素采样 + +`Hd2dLook` 把 HD2DURP 风格的后处理参数写到 `Camera3D`:Gaussian DOF(`Camera3D.setDepthOfField`) +与 Bloom。DOF 在最终 HDR resolve 中读取 GBuffer 硬件深度;因此角色必须用 cutout +深度写入(Sprite3D 默认已开启)。 + +```squirrel +local look = hd2d.newMiniatureLook(); // 或 hd2d.newLook() +look.setFocusDistance(18.0); +look.setFocusRange(12.0); +look.setMaxBlur(6.0); +look.apply(camera); +look.applyPixelSampler(gfx, hero.getTexture()); +``` + +也可用 `camera.setDepthOfField(focusDistance, maxBlurPx, focusRange)` / +`camera.setBloom(intensity, threshold)` 直接配置。 + ## 模块方法与对象 -- `hd2d.getName()`、`hd2d.newTileMap3D()`、`hd2d.newSprite(gfx)` +- `hd2d.getName()`、`hd2d.newTileMap3D()`、`hd2d.newSprite(gfx)`、`hd2d.newLook()`、`hd2d.newMiniatureLook()` - `TileMap3D`:`setSideDepth`/`getSideDepth`、`setHeightScale`/`getHeightScale`、`setWallUV`、`setTint`、`buildMesh`、`buildRenderable`、`getTileCount` -- `Sprite3D`:`setTexture`/`getTexture`、`setFrame`、`setFlipX`/`setFlipY`、`setFrameGrid`、`getFrameGridColumns`/`getFrameGridRows`、`setFrameIndex`/`getFrameIndex`/`getFrameCount`、`play`/`stop`/`isPlaying`/`update`、`setPosition`/`getPositionX`/`getPositionY`/`getPositionZ`、`setSize`/`getWidth`/`getHeight`、`setTint`、`setVisible`/`getVisible` +- `Sprite3D`:`setTexture`/`getTexture`、`setFrame`、`setFlipX`/`setFlipY`、`setFrameGrid`、`getFrameGridColumns`/`getFrameGridRows`、`setFrameIndex`/`getFrameIndex`/`getFrameCount`、`play`/`stop`/`isPlaying`/`update`、`setPosition`/`getPositionX`/`getPositionY`/`getPositionZ`、`setSize`/`getWidth`/`getHeight`、`setTint`、`setVisible`/`getVisible`、`setAlphaCutoff`/`getAlphaCutoff`、`setDepthWrite`/`getDepthWrite`、`setDoubleSided`/`getDoubleSided`、`setBillboardMode`/`getBillboardMode` +- `Hd2dLook`:`setFocusDistance`/`getFocusDistance`、`setMaxBlur`/`getMaxBlur`、`setFocusRange`/`getFocusRange`、`setBloomIntensity`/`getBloomIntensity`、`setBloomThreshold`/`getBloomThreshold`、`apply`、`applyPixelSampler` diff --git a/examples/hd2d-riverside/main.nut b/examples/hd2d-riverside/main.nut index 3eed35b9..ef4a37ba 100644 --- a/examples/hd2d-riverside/main.nut +++ b/examples/hd2d-riverside/main.nut @@ -131,6 +131,16 @@ eve_init = function() { camera.setClipPlanes(1.0, 3000.0); camera.setActive(true); camera.setAmbient(0.25, 0.28, 0.34); + // HD2DURP-style miniature look: shallow DOF + mild bloom. Sprites keep + // depth write via masked cutout so characters stay sharp in the focus band. + local look = hd2d.newLook(); + look.setFocusDistance(900.0); + look.setFocusRange(420.0); + look.setMaxBlur(5.0); + look.setBloomIntensity(0.28); + look.setBloomThreshold(1.25); + look.apply(camera); + look.applyPixelSampler(gfx, atlas); sun = eve.Light3D(); sun.setType("dir"); sun.setDirection(-0.6, 1.0, 0.5); @@ -157,7 +167,9 @@ eve_init = function() { foreach (spawn in riversideData.spawns) { local sprite = hd2d.newSprite(gfx); sprite.setTexture(gfx.newTextureFromFile("assets/" + spawn.id + "/64/final/walk-sheet-clean.png")); - gfx.setTextureSampler(sprite.getTexture(), "nearest", "none", 1.0, 0.0); + look.applyPixelSampler(gfx, sprite.getTexture()); + sprite.setAlphaCutoff(0.5); + sprite.setDepthWrite(true); sprite.setFrameGrid(6, 4); sprite.setSize(64.0, 64.0); sprite.setPivot(0.5, 60.0/64.0); diff --git a/examples/level-designer/README.md b/examples/level-designer/README.md index 1021b621..d257fbc4 100644 --- a/examples/level-designer/README.md +++ b/examples/level-designer/README.md @@ -7,19 +7,35 @@ 这个例子把已有的可组合组件装配成一个 3D 关卡设计工具:引用地形数据 → 放置白盒快速模型 → 调整模型 → 放置玩家出生点 → 用 Motion Matching 控制器在关卡里自由跑动。 +## 编辑器布局 + +界面按常见关卡 / 场景编辑器壳(Unity / Godot / Unreal 一类)拆成五个区域,由 +`editor.newWorkspace` 排布、每个区域一个 UI host(与 `examples/composable-editor` 同壳): + +| 区域 | 面板 | 内容 | +| --- | --- | --- | +| 顶栏 | Toolbar | New / Save / Load · Select / Whitebox / Spawn · Move / Rotate / Scale · Undo / Redo · Play · Frame / Focus | +| 左侧 | Outliner | 地形引用与 Rebuild · 场景层级 | +| 中央 | Viewport | 嵌入式 3D 视口(`ui.viewport` + `renderScene3DToCanvas`)· 状态行 | +| 右侧 | Inspector | 选中物体的名字 / TRS · 白盒 recipe 参数或出生点朝向/人物 | +| 底栏 | Assets | 白盒调色板(Whitebox 工具)或当前工具说明 | + +选中物体时会画出可见的 Transform Gizmo(旧版只有拾取、没有绘制)。 + ## 操作 | 输入 | 作用 | | --- | --- | | `1` / `2` / `3` | 切换 选择 / 白盒 / 出生点 工具 | -| 左键 | 选择工具:拾取物体并开始 Gizmo 拖动;白盒与出生点工具:在鼠标射线与地形交点处放置 | -| 右键拖动 | 环绕相机(绕当前焦点旋转) | -| 中键拖动 | **平移相机焦点**(在关卡里横向移动视角) | -| `Q` / `E` | 拉远 / 拉近 | +| `W` / `R` / `T` | Gizmo:移动 / 旋转 / 缩放 | +| 左键(视口内) | 选择工具:拾取物体并开始 Gizmo 拖动;白盒与出生点工具:在鼠标射线与地形交点处放置 | +| 右键拖动(视口内) | 环绕相机(绕当前焦点旋转) | +| 中键拖动(视口内) | **平移相机焦点**(在关卡里横向移动视角) | +| 滚轮 / `Q` / `E` | 拉远 / 拉近 | | `F` | 聚焦当前选中物体;没有选中时框住整块地形 | | `Delete` | 删除当前选中节点 | | `F5` | 进入 / 退出 Play | -| Play 中移动鼠标 | **视角转动**(第三人称跟随相机绕角色旋转) | +| Play 中在视口内移动鼠标 | **视角转动**(第三人称跟随相机绕角色旋转) | | Play 中 `WASD` + `Shift` | 走 / 跑(方向相对当前视角) | | Play 中 `Q` / `E` | 拉远 / 拉近跟随距离 | | Play 中 `F1` / `F2` | 切换玩家人物 / motion 集合 | @@ -30,15 +46,16 @@ 编辑视角是「绕一个可平移焦点旋转」的轨道相机:`focusX/Y/Z` 是唯一权威,眼位由 `focus + distance * (cosPitch*sinYaw, sinPitch, cosPitch*cosYaw)` 推出。右键绕焦点转, 中键沿视线的地面基(`forward = (-sinYaw, -cosYaw)`、`right = (cosYaw, -sinYaw)`)平移焦点, -`Q/E` 改距离;平移步长按当前距离缩放,因此在任意缩放下手感一致。`F` 把焦点与距离框到选中物体 -(按缩放估算距离),未选中时退回框住整块地形。 +滚轮与 `Q/E` 改距离;平移步长按当前距离缩放,因此在任意缩放下手感一致。`F` 把焦点与距离框到选中物体 +(按缩放估算距离),未选中时退回框住整块地形。射线与相机交互都相对**中央视口**的本地坐标 +(`ui.viewportMouse*` + `canvas` 尺寸),侧栏不会挡住拾取。 Play 视角是第三人称跟随:`CameraController` 的 `follow` 模式求值 `eye = target + offset`, 所以视角完全用一个 offset 表达: `offset = (-sinYaw * cosPitch, sinPitch, -cosYaw * cosPitch) * distance`。 `cameraYaw` 同时是**移动方向的基准**(`levelPlayInputYaw`),因此转视角就是转 WASD 的前方; -角色自身仍朝向实际移动方向。鼠标用绝对光标位置做差分(引擎没有提供 delta API), -超过 200px 的跳变(进出窗口、切换程序)会被忽略以免视角被甩飞;指针悬停在编辑面板上时不转视角。 +角色自身仍朝向实际移动方向。鼠标用视口内绝对光标差分(引擎没有提供 delta API), +超过 200px 的跳变会被忽略;指针不在视口上时不转视角。 ## 地形数据引用 diff --git a/examples/level-designer/level_pick.nut b/examples/level-designer/level_pick.nut index c885f875..00ec706c 100644 --- a/examples/level-designer/level_pick.nut +++ b/examples/level-designer/level_pick.nut @@ -53,11 +53,29 @@ function terrainRayPick(originX, originY, originZ, dirX, dirY, dirZ, maxDistance return null; } -/** @brief Terrain point under the current mouse position, or null. */ +/** + * @brief Viewport-local mouse / canvas size for ray picks, or null when the + * pointer is not over the center viewport. + */ +function levelViewportPointer() { + ui.select("viewport"); + if (!ui.viewportHovered("level-vp")) return null; + local canvas = ui.viewportCanvas("level-vp"); + if (canvas == null) return null; + return { + x = ui.viewportMouseX("level-vp"), + y = ui.viewportMouseY("level-vp"), + w = canvas.getWidth().tofloat(), + h = canvas.getHeight().tofloat() + }; +} + +/** @brief Terrain point under the current viewport mouse, or null. */ function terrainPickAtMouse() { if (level.camera == null) return null; - level.camera.screenToRay(mouse.getX(), mouse.getY(), - config.width.tofloat(), config.height.tofloat()); + local pointer = levelViewportPointer(); + if (pointer == null) return null; + level.camera.screenToRay(pointer.x, pointer.y, pointer.w, pointer.h); return terrainRayPick(level.camera.getScreenRayOriginX(), level.camera.getScreenRayOriginY(), level.camera.getScreenRayOriginZ(), level.camera.getScreenRayDirX(), level.camera.getScreenRayDirY(), level.camera.getScreenRayDirZ(), 6000.0); diff --git a/examples/level-designer/level_play.nut b/examples/level-designer/level_play.nut index 62afcc46..ae2824aa 100644 --- a/examples/level-designer/level_play.nut +++ b/examples/level-designer/level_play.nut @@ -176,6 +176,13 @@ function levelStartPlay() { level.play.focusZ = level.play.positionZ; level.play.lastMouseX = mouse.getX(); level.play.lastMouseY = mouse.getY(); + try { + ui.select("viewport"); + if (ui.viewportHovered("level-vp")) { + level.play.lastMouseX = ui.viewportMouseX("level-vp"); + level.play.lastMouseY = ui.viewportMouseY("level-vp"); + } + } catch (error) {} levelApplyPlayCameraOffset(); level.controller.setTarget(level.play.focusX, level.play.focusY, level.play.focusZ); level.controller.snap(); diff --git a/examples/level-designer/level_state.nut b/examples/level-designer/level_state.nut index f5d99fc6..e17336d4 100644 --- a/examples/level-designer/level_state.nut +++ b/examples/level-designer/level_state.nut @@ -16,16 +16,18 @@ function levelInit() { // module handles session = null, camera = null, controller = null, gizmo = null, model3d = null, animation = null, physics = null, heightmapTargets = null, - world = null, + world = null, workspace = null, mode = "edit", // "edit" | "play" tool = "select", // select | whitebox | spawn + gizmoMode = "translate", // translate | rotate | scale piece = "prototype.cube", recipes = [], // whitebox palette (ProcgenKit prototype recipes) selected = "", objects = [], pieces = {}, // objectId -> {recipe, values, entity, half} spawns = {}, // objectId -> {yaw, character, motionSet, entity, facing} + gizmoPrims = [], // live TransformGizmo overlays (Primitive3D) rigCache = {}, // "character|motionSet" -> rigged character characterName = "Mannequin", motionSetName = "Locomotion", @@ -51,8 +53,11 @@ function levelInit() { function levelStatus(text) { level.status = text; - if (level.session != null) ui.select(levelHost()); - ui.setText("status", text); + // Status lives on the center viewport panel after the workspace redesign. + try { + ui.select("viewport"); + ui.setText("status", text); + } catch (error) {} } // Every scene mutation goes through the session command registry. @@ -94,6 +99,7 @@ function levelSyncGizmo() { level.gizmo.setPosition(t.x, t.y, t.z); level.gizmo.setRotationEuler(t.rotationX, t.rotationY, t.rotationZ); level.gizmo.setScale(t.scaleX, t.scaleY, t.scaleZ); + if (level.gizmoMode != "") level.gizmo.setMode(level.gizmoMode); } function levelNextId(prefix) { diff --git a/examples/level-designer/level_ui.nut b/examples/level-designer/level_ui.nut index cbe3e07f..2b0e9ce0 100644 --- a/examples/level-designer/level_ui.nut +++ b/examples/level-designer/level_ui.nut @@ -1,12 +1,43 @@ -// Editor panels. All of this is host-owned presentation: replacing it does not -// change the session, the level document or any domain operation. +// Classic level-editor chrome: top toolbar, left outliner, center viewport, +// right inspector, bottom asset palette. Presentation only — session / +// document / domain ops stay in the other files. // -// The panel tree is rebuilt only when its structure changes (selection, tool, -// palette, mode). Value edits are applied in place so dragging a slider does not -// destroy the widget under the pointer. +// Pattern matches examples/composable-editor and the domain-editor workspace +// shell (docs/dev/2026-09-04-domain-editor-workspace-ui.md): one UI host per +// region, layout from editor.newWorkspace. function levelMarkPanelsDirty() { level.panelsDirty = true; } +function levelUiHosts() { + return ["toolbar", "hierarchy", "viewport", "inspector", "palette"]; +} + +function levelEventParts(path) { + local slash = path.find("/"); + return slash == null ? ["", path] : [path.slice(0, slash), path.slice(slash + 1)]; +} + +function levelConfigureWorkspace() { + level.workspace = editor.newWorkspace("level-designer.ui", "Level Designer"); + // Top is a single toolbar row (Unity-style); bottom holds the asset strip. + level.workspace.setRegionSize("top", 64.0); + level.workspace.setRegionSize("left", 260.0); + level.workspace.setRegionSize("right", 340.0); + level.workspace.setRegionSize("bottom", 200.0); + level.workspace.layout(config.width.tofloat(), config.height.tofloat()); + level.workspace.registerPanel("toolbar", "Toolbar", "top", 0); + level.workspace.registerPanel("hierarchy", "Outliner", "left", 10); + level.workspace.registerPanel("viewport", "Viewport", "center", 20); + level.workspace.registerPanel("inspector", "Inspector", "right", 30); + level.workspace.registerPanel("palette", "Assets", "bottom", 40); + level.workspace.setPanelCapability("viewport", "scene.viewport.3d"); + level.workspace.setPanelCapability("inspector", "property.selection"); + level.workspace.setPanelContext("hierarchy", "list"); + level.workspace.setPanelContext("viewport", "preview"); + level.workspace.setPanelContext("inspector", "inspector"); + level.workspace.setPanelContext("palette", "asset"); +} + function levelHierarchyOrder() { local ordered = []; function walk(parentId, depth) { @@ -22,177 +53,245 @@ function levelHierarchyOrder() { return ordered; } -function levelToolButton(label, tool) { - ui.button(level.tool == tool ? "[" + label + "]" : label, "tool:" + tool); +function levelToolLabel(tool) { + if (tool == "select") return "Select"; + if (tool == "whitebox") return "Whitebox"; + if (tool == "spawn") return "Spawn"; + return tool; } -function levelMountPanels() { - // Cleared up front, not at the end: if a field in any section throws, the - // panel must degrade to "stale" rather than re-running the whole build on - // every subsequent frame. - level.panelsDirty = false; - ui.beginBuild(); - ui.beginWindow("Level Designer", "root"); - ui.textWrapped(level.status, 352.0, "status"); +function levelGizmoModeLabel() { + if (level.gizmoMode == "rotate") return "Rotate"; + if (level.gizmoMode == "scale") return "Scale"; + return "Move"; +} - ui.beginRow("tools", 4.0); - levelToolButton("Select", "select"); - levelToolButton("Whitebox", "whitebox"); - levelToolButton("Spawn", "spawn"); - ui.end(); - - ui.beginRow("transport", 4.0); - ui.button(level.mode == "play" ? "Stop (Esc)" : "Play (F5)", "play"); - ui.button("Undo", "undo"); - ui.button("Redo", "redo"); - ui.end(); - - ui.beginRow("file-actions", 4.0); +function levelPanelToolbar() { + // One horizontal strip — stacked rows get clipped by the top region height. + ui.beginRow("toolbar-row", 6.0); + ui.button("New", "new"); ui.button("Save", "save"); ui.button("Load", "load"); - ui.button("New", "new"); - ui.button("Frame all", "frame"); - ui.button("Focus selection", "focus"); + ui.separator("tb-sep-file"); + ui.button(level.tool == "select" ? "[Select]" : "Select", "tool:select"); + ui.button(level.tool == "whitebox" ? "[Whitebox]" : "Whitebox", "tool:whitebox"); + ui.button(level.tool == "spawn" ? "[Spawn]" : "Spawn", "tool:spawn"); + ui.separator("tb-sep-gizmo"); + ui.button(level.gizmoMode == "translate" ? "[Move]" : "Move", "gizmo:translate"); + ui.button(level.gizmoMode == "rotate" ? "[Rotate]" : "Rotate", "gizmo:rotate"); + ui.button(level.gizmoMode == "scale" ? "[Scale]" : "Scale", "gizmo:scale"); + ui.separator("tb-sep-hist"); + ui.button("Undo", "undo"); + ui.button("Redo", "redo"); + ui.button(level.mode == "play" ? "Stop" : "Play", "play"); + ui.button("Frame", "frame"); + ui.button("Focus", "focus"); + ui.spacer("tb-fill", 1.0); + ui.badge(level.mode == "play" ? "PLAY" : "EDIT", "mode-badge"); ui.end(); +} - // Terrain reference: either regenerate deterministically from the sampler - // parameters, or reference a terrain asset that already exists on disk. +function levelPanelHierarchy() { + ui.sectionHeader("Terrain", "terrain-header"); local reference = level.terrain != null ? level.terrain.reference : terrainDefaultReference(); - ui.separator("terrain-sep"); - ui.text("Terrain", "terrain-title"); ui.combo("Source", "Generated\nAsset file", reference.mode == "asset" ? 1 : 0, "terrainMode"); if (reference.mode == "asset") ui.inputText("Asset path", reference.path, "terrainPath"); - ui.beginRow("terrain-actions", 4.0); ui.button("Rebuild terrain", "terrainRebuild"); if (level.terrain != null) ui.text(level.terrain.width + "x" + level.terrain.height + " @ " + level.terrain.spacingX + "m", "terrain-info"); - ui.end(); ui.separator("hierarchy-sep"); - ui.text("Hierarchy", "hierarchy-title"); - ui.beginScrollList("hierarchy", 190.0, 0.0); + ui.sectionHeader("Hierarchy", "hierarchy-header"); + ui.text(level.objects.len() + " objects · rev " + level.revision, "hierarchy-meta"); + local listH = level.workspace.getRegionH("left") - 210.0; + if (listH < 120.0) listH = 120.0; + ui.beginScrollList("hierarchy", listH, 0.0); foreach (entry in levelHierarchyOrder()) { local indent = ""; - for (local i = 0; i < entry.depth; ++i) indent += " "; - local tag = entry.spawn ? " *" : (entry.piece ? " #" : ""); - ui.button(indent + (entry.id == level.selected ? "> " : "") + entry.name + tag, - "select:" + entry.id); + for (local i = 0; i < entry.depth; ++i) indent += " "; + local tag = entry.spawn ? " [spawn]" : (entry.piece ? " [box]" : ""); + local mark = entry.id == level.selected ? "> " : " "; + ui.button(indent + mark + entry.name + tag, "select:" + entry.id); } ui.end(); +} +function levelPanelViewport() { + local help = level.mode == "play" + ? "Mouse look · WASD+Shift · Q/E zoom · F1/F2 · Esc" + : "LMB select/place · RMB orbit · MMB pan · wheel zoom · F focus · Del · 1/2/3"; + ui.text(help, "viewport-help"); + local vpW = level.workspace.getRegionW("center") - 16.0; + local vpH = level.workspace.getRegionH("center") - 78.0; + if (vpW < 64.0) vpW = 64.0; + if (vpH < 64.0) vpH = 64.0; + ui.viewport("level-vp", vpW, vpH); + ui.text(level.status, "status"); + if (level.mode == "play" && level.play != null) { + local play = level.play; + local speed = sqrt(play.rig.matcher.getDesiredVelocityX() * play.rig.matcher.getDesiredVelocityX() + + play.rig.matcher.getDesiredVelocityZ() * play.rig.matcher.getDesiredVelocityZ()); + ui.progress(levelClamp(speed / 4.4, 0.0, 1.0), "play-speed", "Speed"); + ui.text((play.grounded ? "Grounded" : "Airborne") + " · " + play.characterName + + " / " + play.motionSetName, "play-state"); + } +} + +function levelPanelInspector() { + ui.sectionHeader("Selection", "selection-header"); local object = levelObject(level.selected); - if (object != null) { - ui.separator("inspector-sep"); - ui.text("Selection", "selection-title"); - ui.inputText("Name", object.name, "name"); - ui.beginRow("position-row", 4.0); - ui.inputText("x", object.transform.x.tostring(), "px"); - ui.inputText("y", object.transform.y.tostring(), "py"); - ui.inputText("z", object.transform.z.tostring(), "pz"); - ui.end(); - ui.beginRow("rot-scale-row", 4.0); - ui.inputText("yaw", object.transform.rotationY.tostring(), "ry"); - ui.inputText("scale", object.transform.scaleX.tostring(), "sx"); - ui.end(); - ui.beginRow("object-actions", 4.0); - ui.button("Apply", "apply"); - ui.button("Drop to terrain", "drop"); - ui.button("Delete", "delete"); - ui.end(); + if (object == null) { + ui.textWrapped("Nothing selected. Click an object in the viewport or hierarchy, or switch to Whitebox / Spawn and click the terrain.", + 268.0, "selection-empty"); + ui.separator("tool-context-sep"); + ui.text("Active tool: " + levelToolLabel(level.tool), "tool-context"); + ui.text("Gizmo: " + levelGizmoModeLabel(), "gizmo-context"); + return; } + ui.inputText("Name", object.name, "name"); + ui.text("id: " + object.id, "object-id"); + ui.separator("trs-sep"); + ui.sectionHeader("Transform", "trs-header"); + ui.beginRow("position-row", 4.0); + ui.inputText("X", object.transform.x.tostring(), "px"); + ui.inputText("Y", object.transform.y.tostring(), "py"); + ui.inputText("Z", object.transform.z.tostring(), "pz"); + ui.end(); + ui.beginRow("rot-scale-row", 4.0); + ui.inputText("Yaw", object.transform.rotationY.tostring(), "ry"); + ui.inputText("Scale", object.transform.scaleX.tostring(), "sx"); + ui.end(); + ui.beginRow("object-actions", 4.0); + ui.button("Apply", "apply"); + ui.button("Drop to terrain", "drop"); + ui.button("Delete", "delete"); + ui.end(); + + if (object.id in level.pieces) { + local piece = level.pieces[object.id]; + ui.separator("piece-params-sep"); + ui.sectionHeader("Whitebox parameters", "piece-title"); + ui.text(piece.recipe.slice("prototype.".len()), "piece-recipe"); + local schema = whiteboxSchema(piece.recipe); + if (schema != null) { + local paramsH = level.workspace.getRegionH("right") - 340.0; + if (paramsH < 100.0) paramsH = 100.0; + ui.beginScrollList("piece-params", paramsH, 0.0); + for (local i = 0; i < schema.getParamCount(); ++i) { + local key = schema.getParamKey(i); + if (!(key in piece.values)) continue; + local kind = schema.getParamKind(i); + local id = "pp:" + key; + if (kind == "float" || kind == "int") { + local minimum = schema.paramHasMinimum(i) ? schema.getParamMinimum(i) : 0.0; + local maximum = schema.paramHasMaximum(i) ? schema.getParamMaximum(i) : 100.0; + ui.slider(schema.getParamLabel(i), piece.values[key].tofloat(), + minimum, maximum, id); + } else if (kind == "bool") { + ui.checkbox(schema.getParamLabel(i), piece.values[key].tointeger() != 0, id); + } else if (kind == "choice") { + local choices = "", selected = 0; + for (local c = 0; c < schema.getParamChoiceCount(i); ++c) { + local choice = schema.getParamChoice(i, c); + if (choice == piece.values[key]) selected = c; + choices += (c == 0 ? "" : "\n") + choice; + } + ui.combo(schema.getParamLabel(i), choices, selected, id); + } else { + ui.inputText(schema.getParamLabel(i), piece.values[key], id); + } + } + ui.end(); + } + } + + if (object.id in level.spawns) { + local spawn = level.spawns[object.id]; + ui.separator("spawn-sep"); + ui.sectionHeader("Spawn point", "spawn-title"); + ui.slider("Facing yaw (deg)", spawn.yaw * 57.2957795, -180.0, 180.0, "spawnYaw"); + local spawnCharacter = spawn.character != "" ? spawn.character : levelActiveCharacterName(); + if (levelCharacterNames().len() > 0) { + local options = "", selected = 0, index = 0; + foreach (name in levelCharacterNames()) { + if (name == spawnCharacter) selected = index; + options += (index == 0 ? "" : "\n") + name; + index += 1; + } + ui.combo("Character", options, selected, "spawnCharacter"); + } + local spawnMotionSet = spawn.motionSet != "" ? spawn.motionSet : levelActiveMotionSetName(); + if (levelMotionSetNames().len() > 0) { + local options = "", selected = 0, index = 0; + foreach (name in levelMotionSetNames()) { + if (name == spawnMotionSet) selected = index; + options += (index == 0 ? "" : "\n") + name; + index += 1; + } + ui.combo("Motion set", options, selected, "spawnMotionSet"); + } + } +} + +function levelPanelPalette() { if (level.tool == "whitebox") { - ui.separator("palette-sep"); - ui.text("Whitebox palette (" + level.recipes.len() + " pieces)", "palette-title"); - ui.beginScrollList("palette", 210.0, 0.0); + ui.sectionHeader("Whitebox palette · " + level.recipes.len() + " recipes", "palette-header"); + ui.text("Active: " + level.piece.slice("prototype.".len()) + " · click terrain to place", + "palette-active"); + local listH = level.workspace.getRegionH("bottom") - 70.0; + if (listH < 80.0) listH = 80.0; + // Flat scroll list — nested beginRow wrapping clipped most recipes. + ui.beginScrollList("palette", listH, 0.0); foreach (recipe in level.recipes) { local label = recipe.slice("prototype.".len()); ui.button(level.piece == recipe ? "> " + label : label, "piece:" + recipe); } ui.end(); - local piece = (level.selected in level.pieces) ? level.pieces[level.selected] : null; - if (piece != null) { - ui.separator("piece-params-sep"); - ui.text("Piece parameters", "piece-title"); - local schema = whiteboxSchema(piece.recipe); - if (schema != null) { - ui.beginScrollList("piece-params", 240.0, 0.0); - for (local i = 0; i < schema.getParamCount(); ++i) { - local key = schema.getParamKey(i); - if (!(key in piece.values)) continue; - local kind = schema.getParamKind(i); - local id = "pp:" + key; - if (kind == "float" || kind == "int") { - local minimum = schema.paramHasMinimum(i) ? schema.getParamMinimum(i) : 0.0; - local maximum = schema.paramHasMaximum(i) ? schema.getParamMaximum(i) : 100.0; - ui.slider(schema.getParamLabel(i), piece.values[key].tofloat(), - minimum, maximum, id); - } else if (kind == "bool") { - ui.checkbox(schema.getParamLabel(i), piece.values[key].tointeger() != 0, id); - } else if (kind == "choice") { - local choices = "", selected = 0; - for (local c = 0; c < schema.getParamChoiceCount(i); ++c) { - local choice = schema.getParamChoice(i, c); - if (choice == piece.values[key]) selected = c; - choices += (c == 0 ? "" : "\n") + choice; - } - ui.combo(schema.getParamLabel(i), choices, selected, id); - } else { - ui.inputText(schema.getParamLabel(i), piece.values[key], id); - } - } - ui.end(); - } - } + } else if (level.tool == "spawn") { + ui.sectionHeader("Spawn tool", "palette-header"); + ui.textWrapped("Click the terrain in the viewport to drop a player start. Select a spawn in the outliner to edit facing, character, and motion set in the inspector.", + level.workspace.getRegionW("bottom") - 24.0, "palette-spawn-help"); + ui.text("Spawn points: " + spawnCount(), "palette-spawn-count"); + } else { + ui.sectionHeader("Select tool", "palette-header"); + ui.textWrapped("Click objects in the viewport or outliner. Drag the visible gizmo to move / rotate / scale. Use the inspector for precise transforms.", + level.workspace.getRegionW("bottom") - 24.0, "palette-select-help"); + local sel = levelObject(level.selected); + ui.text(sel == null ? "No selection" : ("Selected: " + sel.name), "palette-selection"); } +} - if (level.tool == "spawn") { - ui.separator("spawn-sep"); - ui.text("Spawn points (" + spawnCount() + ")", "spawn-title"); - local spawn = (level.selected in level.spawns) ? level.spawns[level.selected] : null; - if (spawn == null) { - ui.textWrapped("Select or place a spawn point to edit its facing and start character.", - 340.0, "spawn-help"); - } else { - ui.slider("Facing yaw (deg)", spawn.yaw * 57.2957795, -180.0, 180.0, "spawnYaw"); - // The combo shows this spawn point's own start character, falling back to - // the level default only while the spawn has not chosen one. - local spawnCharacter = spawn.character != "" ? spawn.character : levelActiveCharacterName(); - if (levelCharacterNames().len() > 0) { - local options = "", selected = 0, index = 0; - foreach (name in levelCharacterNames()) { - if (name == spawnCharacter) selected = index; - options += (index == 0 ? "" : "\n") + name; - index += 1; - } - ui.combo("Character", options, selected, "spawnCharacter"); - } - local spawnMotionSet = spawn.motionSet != "" ? spawn.motionSet : levelActiveMotionSetName(); - if (levelMotionSetNames().len() > 0) { - local options = "", selected = 0, index = 0; - foreach (name in levelMotionSetNames()) { - if (name == spawnMotionSet) selected = index; - options += (index == 0 ? "" : "\n") + name; - index += 1; - } - ui.combo("Motion set", options, selected, "spawnMotionSet"); - } - } +levelPanelBuilders <- { + toolbar = levelPanelToolbar, + hierarchy = levelPanelHierarchy, + viewport = levelPanelViewport, + inspector = levelPanelInspector, + palette = levelPanelPalette +}; + +function levelMountPanels() { + // Cleared up front: if a field throws, degrade to stale instead of + // rebuilding every frame. + level.panelsDirty = false; + if (level.workspace == null) levelConfigureWorkspace(); + level.workspace.layout(config.width.tofloat(), config.height.tofloat()); + for (local i = 0; i < level.workspace.getPanelCount(); ++i) { + if (!level.workspace.getPanelVisible(i)) continue; + local id = level.workspace.getPanelId(i); + if (!(id in levelPanelBuilders)) continue; + ui.beginBuild(); + ui.beginWindow(level.workspace.getPanelTitle(i), "root"); + levelPanelBuilders[id](); + ui.end(); + ui.mountBuildAs(id); + ui.select(id); + local region = level.workspace.getPanelRegion(i); + ui.setHostPos(level.workspace.getRegionX(region), level.workspace.getRegionY(region), 0.0, 0.0); + ui.setHostSize(level.workspace.getRegionW(region), level.workspace.getRegionH(region)); + ui.setHostOverlay(false); } - - ui.separator("help-sep"); - ui.textWrapped(level.mode == "play" - ? "Mouse look turns the view (and with it WASD forward). Q/E zoom | WASD + Shift run | F1 character | F2 motion set | Esc stop." - : "Whitebox: pick a piece, click the terrain to place it. Spawn: click the terrain to drop a player start. LMB select/drag gizmo, RMB orbit, MMB pan, Q/E zoom, F focus, Del deletes.", - 352.0, "help"); - ui.end(); - - ui.mountBuildAs(levelHost()); - ui.select(levelHost()); - ui.setHostPos(14.0, 14.0, 0.0, 0.0); - ui.setHostSize(400.0, config.height - 28.0); - ui.setHostOverlay(true); } function levelRefreshPanels() { @@ -205,6 +304,7 @@ function levelApplySelectionFields() { if (object == null) return; local px, py, pz, ry, sx; try { + ui.select("inspector"); px = ui.getValueText("px").tofloat(); py = ui.getValueText("py").tofloat(); pz = ui.getValueText("pz").tofloat(); @@ -245,6 +345,7 @@ function levelUpdatePieceParam(id, key) { if (piece == null) return; local schema = whiteboxSchema(piece.recipe); if (schema == null) return; + ui.select("inspector"); for (local i = 0; i < schema.getParamCount(); ++i) { if (schema.getParamKey(i) != key) continue; local kind = schema.getParamKind(i); @@ -258,41 +359,43 @@ function levelUpdatePieceParam(id, key) { } function levelHandleUi() { - ui.select(levelHost()); + // Global click/change queues carry "host/widget" paths — route by prefix. local click = ui.consumeClick(); while (click != "") { - local slash = click.find("/"); - local id = slash == null ? click : click.slice(slash + 1); - levelHandleAction(id); - ui.select(levelHost()); + local parts = levelEventParts(click); + levelHandleAction(parts[1]); click = ui.consumeClick(); } local change = ui.consumeChange(); while (change != "") { - local slash = change.find("/"); - local id = slash == null ? change : change.slice(slash + 1); + local parts = levelEventParts(change); + local id = parts[1]; if (id.find("pp:") == 0) levelUpdatePieceParam(id, id.slice(3)); else if (id == "terrainMode") { + ui.select("hierarchy"); if (level.terrain != null) { level.terrain.reference.mode = ui.getValue("terrainMode").tointeger() == 1 ? "asset" : "generated"; level.status = "terrain source: " + level.terrain.reference.mode + " (press Rebuild terrain)"; } levelMarkPanelsDirty(); } else if (id == "terrainPath") { + ui.select("hierarchy"); if (level.terrain != null) level.terrain.reference.path = ui.getValueText("terrainPath"); } else if (id == "spawnYaw") { + ui.select("inspector"); local spawn = (level.selected in level.spawns) ? level.spawns[level.selected] : null; if (spawn != null) spawn.yaw = ui.getValue("spawnYaw") / 57.2957795; } else if (id == "spawnCharacter") { + ui.select("inspector"); local spawn = (level.selected in level.spawns) ? level.spawns[level.selected] : null; local names = levelCharacterNames(); if (spawn != null && names.len() > 0) spawn.character = names[ui.getValue("spawnCharacter").tointeger()]; } else if (id == "spawnMotionSet") { + ui.select("inspector"); local spawn = (level.selected in level.spawns) ? level.spawns[level.selected] : null; local names = levelMotionSetNames(); if (spawn != null && names.len() > 0) spawn.motionSet = names[ui.getValue("spawnMotionSet").tointeger()]; } - ui.select(levelHost()); change = ui.consumeChange(); } } @@ -300,6 +403,12 @@ function levelHandleUi() { function levelHandleAction(id) { if (id == "tool:select" || id == "tool:whitebox" || id == "tool:spawn") { level.tool = id.slice(5); + level.status = "tool: " + levelToolLabel(level.tool); + levelMarkPanelsDirty(); + } else if (id == "gizmo:translate" || id == "gizmo:rotate" || id == "gizmo:scale") { + level.gizmoMode = id.slice(6); + if (level.gizmo != null) level.gizmo.setMode(level.gizmoMode); + level.status = "gizmo: " + levelGizmoModeLabel(); levelMarkPanelsDirty(); } else if (id == "play") { levelTogglePlay(); diff --git a/examples/level-designer/main.nut b/examples/level-designer/main.nut index 315b2c31..b3c66a6f 100644 --- a/examples/level-designer/main.nut +++ b/examples/level-designer/main.nut @@ -4,6 +4,9 @@ // undo/redo and conflict detection behave exactly as in examples/scene-editor. // Play mode (F5) builds a runtime world from the authored data and drives a // Motion Matching character; it never mutates authored state. +// +// UI is a classic editor workspace (toolbar / outliner / viewport / inspector / +// asset palette) — see level_ui.nut. dofile("level_state.nut"); dofile("level_json.nut"); @@ -123,6 +126,88 @@ function levelPreviewSelection() { if (level.selected in level.spawns) spawnApplyTransform(level.spawns[level.selected], preview); } +/** @brief Parent-local gizmo point → world, for overlay drawing. */ +function levelGizmoWorldPoint(object, lx, ly, lz) { + if (object == null || object.parent == "") return [lx, ly, lz]; + return scene.localToWorldAt(levelHost(), object.parent, lx, ly, lz); +} + +function levelClearGizmoPrims() { + foreach (prim in level.gizmoPrims) { + if (prim == null) continue; + try { + local removed = prim.remove(); + // Best-effort: a stale proxy after hot-reload is fine to drop. + if (removed != null && !removed.ok) {} + } catch (error) {} + } + level.gizmoPrims = []; +} + +/** + * @brief Draw the TransformGizmo as world primitives (missing in the old UI — + * pick/drag worked but nothing was visible). + * + * Primitive factories return Result{ok,value}; keep the owned proxy and set + * depth to ignore so the gizmo stays readable over terrain. + */ +function levelDrawGizmo() { + levelClearGizmoPrims(); + if (level.mode == "play") return; + local object = levelObject(level.selected); + if (object == null || level.gizmo == null) return; + local gizmo = level.gizmo; + + function keep(result) { + if (result == null || !result.ok) return; + local prim = result.value; + local depth = prim.setDepthMode("ignore"); + if (depth != null && !depth.ok) {} + level.gizmoPrims.push(prim); + } + + for (local i = 0; i < gizmo.getPartCount(); ++i) { + local ox = gizmo.getPartOriginX(i), oy = gizmo.getPartOriginY(i), oz = gizmo.getPartOriginZ(i); + local length = gizmo.getPartLength(i); + local a = levelGizmoWorldPoint(object, ox, oy, oz); + local b = levelGizmoWorldPoint(object, + ox + gizmo.getPartDirX(i) * length, + oy + gizmo.getPartDirY(i) * length, + oz + gizmo.getPartDirZ(i) * length); + local kind = gizmo.getPartKind(i); + local r = gizmo.getPartColorR(i), g = gizmo.getPartColorG(i), bl = gizmo.getPartColorB(i); + if (kind == "axis") { + if (gizmo.getMode() == "translate") + keep(gfx.newPrimitiveArrow3D(a[0], a[1], a[2], b[0], b[1], b[2], + length * 0.22, length * 0.08, r, g, bl, 1.0, 3.0)); + else + keep(gfx.newPrimitiveLine3D(a[0], a[1], a[2], b[0], b[1], b[2], r, g, bl, 1.0, 3.0)); + } else if (kind == "center") { + local rad = gizmo.getPartRadius(i) * 0.75; + keep(gfx.newPrimitiveLine3D(a[0] - rad, a[1], a[2], a[0] + rad, a[1], a[2], r, g, bl, 1.0, 2.0)); + } else if (kind == "ring") { + local normal = [gizmo.getPartDirX(i), gizmo.getPartDirY(i), gizmo.getPartDirZ(i)]; + local u = fabs(normal[1]) < 0.9 ? [-normal[2], 0.0, normal[0]] : [0.0, normal[2], -normal[1]]; + local un = sqrt(u[0] * u[0] + u[1] * u[1] + u[2] * u[2]); + if (un < 0.0001) continue; + for (local k = 0; k < 3; ++k) u[k] /= un; + local v = [normal[1] * u[2] - normal[2] * u[1], + normal[2] * u[0] - normal[0] * u[2], + normal[0] * u[1] - normal[1] * u[0]]; + local points = [], radius = gizmo.getPartRadius(i); + for (local k = 0; k < 48; ++k) { + local angle = k * 6.28318530718 / 48.0; + local p = levelGizmoWorldPoint(object, + ox + radius * (u[0] * cos(angle) + v[0] * sin(angle)), + oy + radius * (u[1] * cos(angle) + v[1] * sin(angle)), + oz + radius * (u[2] * cos(angle) + v[2] * sin(angle))); + points.push(p[0]); points.push(p[1]); points.push(p[2]); + } + keep(gfx.newPrimitivePolyline3D(points, true, r, g, bl, 1.0, 3.0)); + } + } +} + function levelPointer() { local down = mouse.isDown(1); local pressed = down && !level.mouseDown; @@ -130,13 +215,13 @@ function levelPointer() { level.mouseDown = down; local gizmo = level.gizmo; - if (ui.wantCaptureMouse()) { + local pointer = levelViewportPointer(); + if (pointer == null) { if (gizmo.isDragging()) gizmo.endDrag(); return; } local object = levelObject(level.selected); - level.camera.screenToRay(mouse.getX(), mouse.getY(), - config.width.tofloat(), config.height.tofloat()); + level.camera.screenToRay(pointer.x, pointer.y, pointer.w, pointer.h); local ox = level.camera.getScreenRayOriginX(); local oy = level.camera.getScreenRayOriginY(); local oz = level.camera.getScreenRayOriginZ(); @@ -215,9 +300,23 @@ function levelEditInput() { if (levelKeyPressed("1")) levelHandleAction("tool:select"); if (levelKeyPressed("2")) levelHandleAction("tool:whitebox"); if (levelKeyPressed("3")) levelHandleAction("tool:spawn"); + if (levelKeyPressed("w") || levelKeyPressed("W")) levelHandleAction("gizmo:translate"); + if (levelKeyPressed("r") || levelKeyPressed("R")) levelHandleAction("gizmo:rotate"); + if (levelKeyPressed("t") || levelKeyPressed("T")) levelHandleAction("gizmo:scale"); - local mouseX = mouse.getX(), mouseY = mouse.getY(); - local orbit = mouse.isDown(2) && !ui.wantCaptureMouse(); + ui.select("viewport"); + local hovered = ui.viewportHovered("level-vp"); + local mouseX = hovered ? ui.viewportMouseX("level-vp") : mouse.getX(); + local mouseY = hovered ? ui.viewportMouseY("level-vp") : mouse.getY(); + if (hovered) { + local wheel = ui.viewportWheel("level-vp"); + if (wheel != 0.0) { + if (wheel > 0.0) level.distance = levelClamp(level.distance / 1.12, 3.0, 3000.0); + else level.distance = levelClamp(level.distance * 1.12, 3.0, 3000.0); + levelUpdateOrbitCamera(); + } + } + local orbit = hovered && mouse.isDown(2); if (orbit && level.orbit) { level.yaw += (mouseX - level.lastX) * 0.008; level.pitch += (mouseY - level.lastY) * 0.006; @@ -225,7 +324,7 @@ function levelEditInput() { levelUpdateOrbitCamera(); } // Middle-drag pans the focus across the level. - local pan = mouse.isDown(3) && !ui.wantCaptureMouse(); + local pan = hovered && mouse.isDown(3); if (pan && level.pan) levelPanCamera(mouseX - level.lastX, mouseY - level.lastY); level.orbit = orbit; level.pan = pan; @@ -252,16 +351,13 @@ function levelPlayInput() { local play = level.play; if (play == null) return; - // Mouse look. The engine exposes absolute cursor positions and no delta API, - // so the delta is tracked here and the panel area is excluded so hovering the - // editor UI does not spin the view. - local mouseX = mouse.getX(), mouseY = mouse.getY(); + // Mouse look inside the viewport only, so docking chrome never spins the view. + ui.select("viewport"); + local hovered = ui.viewportHovered("level-vp"); + local mouseX = hovered ? ui.viewportMouseX("level-vp") : play.lastMouseX; + local mouseY = hovered ? ui.viewportMouseY("level-vp") : play.lastMouseY; local deltaX = mouseX - play.lastMouseX, deltaY = mouseY - play.lastMouseY; - // Absolute cursor polling has no notion of a discontinuity, so a jump larger - // than a deliberate flick (leaving/entering the window, alt-tab) is ignored - // instead of whipping the view around. - if (!ui.wantCaptureMouse() && (deltaX < 200.0 && deltaX > -200.0) && - (deltaY < 200.0 && deltaY > -200.0)) + if (hovered && (deltaX < 200.0 && deltaX > -200.0) && (deltaY < 200.0 && deltaY > -200.0)) levelPlayLook(deltaX, deltaY); play.lastMouseX = mouseX; play.lastMouseY = mouseY; @@ -272,20 +368,6 @@ function levelPlayInput() { play.cameraDistance = levelClamp(play.cameraDistance / 1.15, 2.0, 60.0); } -function levelDrawOverlay() { - if (level.mode != "play") return; - local play = level.play; - gfx.drawSolidRect(18.0, 18.0, 470.0, 84.0, 0.02, 0.035, 0.06, 0.88); - gfx.drawSolidRect(30.0, 30.0, 20.0, 20.0, 0.20, 0.85, 0.45, 1.0); - gfx.drawSolidRect(62.0, 30.0, 390.0, 8.0, 0.18, 0.24, 0.32, 1.0); - local speed = sqrt(play.rig.matcher.getDesiredVelocityX() * play.rig.matcher.getDesiredVelocityX() + - play.rig.matcher.getDesiredVelocityZ() * play.rig.matcher.getDesiredVelocityZ()); - gfx.drawSolidRect(62.0, 30.0, 390.0 * levelClamp(speed / 4.4, 0.0, 1.0), 8.0, 0.25, 0.86, 0.48, 1.0); - gfx.drawSolidRect(62.0, 52.0, play.grounded ? 180.0 : 55.0, 8.0, - play.grounded ? 0.30 : 0.85, play.grounded ? 0.80 : 0.45, 0.50, 1.0); - gfx.drawSolidRect(62.0, 70.0, 120.0 * levelClamp(play.lastCost / 6.0, 0.0, 1.0), 8.0, 0.95, 0.60, 0.20, 1.0); -} - eve_init = function() { levelInit(); level.model3d = eve.Model3D(); @@ -300,11 +382,13 @@ eve_init = function() { level.gizmo = eve.Editor().newGizmo(); level.gizmo.setSize(2.4); level.gizmo.setSnapTranslate(0.5, 0.5, 0.5); + level.gizmo.setMode(level.gizmoMode); - gfx.setBackgroundColor(0.055, 0.065, 0.085, 1.0); + gfx.setBackgroundColor(0.045, 0.052, 0.068, 1.0); gfx.setDirectionalLight(-0.42, -1.0, -0.30, 1.25, 1.18, 1.05); ui.setTheme("dark"); levelSetupCamera(); + levelConfigureWorkspace(); if (terrainBuild(terrainDefaultReference()) == null) throw level.status; levelFrameTerrain(); @@ -313,9 +397,11 @@ eve_init = function() { print("level-designer: terrain " + level.terrain.width + "x" + level.terrain.height + " spacing=" + level.terrain.spacingX + " whiteboxRecipes=" + level.recipes.len() + "\n"); - print("level-designer: 1/2/3 tools | LMB place/select | RMB orbit | MMB pan | Q/E zoom | F focus | F5 play\n"); - print("level-designer: in play: mouse look | WASD+Shift | Q/E zoom | F1/F2 character/motion | Esc stop\n"); - level.status = "ready: pick a piece, then click the terrain"; + print("level-designer: workspace panels=" + level.workspace.getPanelCount() + + " (toolbar/outliner/viewport/inspector/palette)\n"); + print("level-designer: 1/2/3 tools | W/R/T gizmo | LMB place/select | RMB orbit | MMB pan | wheel/Q/E zoom | F focus | F5 play\n"); + print("level-designer: in play: mouse look (viewport) | WASD+Shift | Q/E zoom | F1/F2 character/motion | Esc stop\n"); + level.status = "ready: pick Whitebox, choose a piece, click the terrain"; }; eve_update = function(dt) { @@ -327,6 +413,8 @@ eve_update = function(dt) { levelPlayInput(); if (level.mode == "play") levelUpdatePlay(dt); levelHandleUi(); + if (level.panelsDirty) levelMountPanels(); + levelClearGizmoPrims(); return; } levelHandleUi(); @@ -335,12 +423,14 @@ eve_update = function(dt) { levelPointer(); whiteboxSyncTransforms(); spawnSyncTransforms(); + levelDrawGizmo(); }; eve_render = function() { gfx.clear(); - gfx.render3D(); - levelDrawOverlay(); + ui.select("viewport"); + local canvas = ui.viewportCanvas("level-vp"); + if (canvas != null) gfx.renderScene3DToCanvas(canvas, level.camera); ui.beginFrameAndRender(); }; @@ -348,6 +438,7 @@ eve_reload <- function() { // The level state holds live module handles (session, gizmo, camera, procgen // module, camera controller) that a hot-reload snapshot cannot capture, so the // designer rebuilds its session from scratch instead of using stale handles. + levelClearGizmoPrims(); level = null; eve_init(); level.status = "hot reloaded"; diff --git a/examples/level-designer/smoke.nut b/examples/level-designer/smoke.nut index 5f7692ff..f748b818 100644 --- a/examples/level-designer/smoke.nut +++ b/examples/level-designer/smoke.nut @@ -131,6 +131,8 @@ foreach (tool in ["select", "whitebox", "spawn"]) { smokeCheck("panels build in every tool and selection state", panelFailure == ""); if (panelFailure != "") print(" panel failure: " + panelFailure + "\n"); smokeCheck("panel build clears the dirty flag", !level.panelsDirty); +smokeCheck("workspace shell mounted", level.workspace != null && level.workspace.getPanelCount() >= 5); + level.tool = "whitebox"; level.selected = spawnObject; diff --git a/scripts/compile_graphics_dof_shaders.py b/scripts/compile_graphics_dof_shaders.py new file mode 100644 index 00000000..ff8e5f4b --- /dev/null +++ b/scripts/compile_graphics_dof_shaders.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Compile the graphics depth-of-field GLSL fragment to a SPIR-V C++ include array.""" + +from __future__ import annotations + +import struct +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SHADER_DIR = ROOT / "src" / "modules" / "graphics" / "shaders" + +FRAGS = [ + "dof_gaussian.frag", +] + + +def spv_to_inc(spv_path: Path, array_name: str, out_path: Path) -> None: + data = spv_path.read_bytes() + if len(data) % 4 != 0: + raise SystemExit(f"SPIR-V size not multiple of 4: {spv_path}") + words = struct.unpack(f"<{len(data) // 4}I", data) + if not words or words[0] != 0x07230203: + raise SystemExit(f"bad SPIR-V magic in {spv_path}") + lines = [f"static const uint32_t {array_name}[] = {{"] + row: list[str] = [] + for i, w in enumerate(words): + row.append(f"0x{w:08x}") + if len(row) == 8: + lines.append(" " + ", ".join(row) + ",") + row = [] + if row: + lines.append(" " + ", ".join(row) + ",") + lines.append("};") + lines.append(f"static const size_t {array_name}_count = {len(words)};") + lines.append("") + out_path.write_text("\n".join(lines), encoding="utf-8") + print(f"wrote {out_path.relative_to(ROOT)} ({len(words)} words)") + + +def compile_frag(src: Path) -> Path: + out = src.with_suffix(".spv") + cmd = ["glslc", "-fshader-stage=frag", str(src), "-o", str(out)] + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + raise SystemExit(f"glslc failed for {src.name}:\n{r.stderr or r.stdout}") + return out + + +def main() -> int: + if not SHADER_DIR.is_dir(): + raise SystemExit(f"missing shader dir: {SHADER_DIR}") + for name in FRAGS: + src = SHADER_DIR / name + if not src.is_file(): + raise SystemExit(f"missing {src}") + spv = compile_frag(src) + stem = src.stem + array = f"{stem}_frag_spv" + spv_to_inc(spv, array, SHADER_DIR / f"{stem}_frag_spv.inc") + spv.unlink(missing_ok=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/engine/devtools/DevTool.cpp b/src/engine/devtools/DevTool.cpp index f41add73..8b9f0448 100644 --- a/src/engine/devtools/DevTool.cpp +++ b/src/engine/devtools/DevTool.cpp @@ -135,6 +135,10 @@ void DevTool::attach(ssq::VM& vm, bool sampleLocals) { attach(vm.getHandle(), sa void DevTool::attach(eve::Runtime& runtime, bool sampleLocals) { attach(runtime.vm(), sampleLocals); runtime_ = &runtime; + // Capture throw-site stacks for try/catch so reportError / lastScriptError + // can surface the original site (load.nut pattern). Safe here because the + // Runtime-bound error hook only stashes context and does not notify/pause. + sq_notifyallexceptions(runtime.handle(), SQTrue); // Route Runtime-boundary errors into the slicer/report. The VM error hook // only captures the stack when this Runtime sink is bound; this handler // reports compile / reflect / unload failures and uncaught execute errors @@ -182,7 +186,11 @@ void DevTool::attach(HSQUIRRELVM vm, bool sampleLocals) { Debugger::instance().setPump([this]() { pumpWhilePaused(); }); sq_enabledebuginfo(vm_, SQTrue); - sq_notifyallexceptions(vm_, SQTrue); + // Do not enable sq_notifyallexceptions here. With notify-all + break-on-error, + // this hook would pause on every caught throw before script catch/reportError + // runs (double pause → hung script thread → flaky teardown SEGFAULT in + // devtools.dap.caughtErrorPausesAtReportSite). Runtime::installErrorHandler + // already turns notify-all on for production VMs; attach(Runtime&) keeps it. sq_setnativedebughook(vm_, nativeDebugHook); // Route uncaught script errors into the debugger (break-on-error aware). sq_newclosure(vm_, runtimeErrorHook, 0); diff --git a/src/modules/graphics/DepthOfField.cpp b/src/modules/graphics/DepthOfField.cpp new file mode 100644 index 00000000..f8eca961 --- /dev/null +++ b/src/modules/graphics/DepthOfField.cpp @@ -0,0 +1,102 @@ +#include "graphics/DepthOfField.h" + +#include +#include +#include + +#include "common/Exception.h" +#include "graphics/BlendMode.h" +#include "graphics/Canvas.h" +#include "graphics/Color.h" +#include "graphics/Graphics.h" +#include "graphics/Shader.h" +#include "graphics/Texture.h" +#include "graphics/shaders/PostProcessWgsl.h" +#include "graphics/shaders/dof_gaussian_frag_spv.inc" + +namespace eve::graphics { +namespace { + +std::vector copySpv(const uint32_t *data, size_t count) { + return std::vector(data, data + count); +} + +Shader *newDofShader(Graphics *gfx) { + Shader *shader = + gfx->getBackendName() == "webgpu" + ? gfx->newShaderFromWgsl({}, std::string(shaders::kPostCommon) + shaders::kDofGaussian) + : gfx->newShaderFromSpv({}, + copySpv(dof_gaussian_frag_spv, dof_gaussian_frag_spv_count)); + if (!shader || !shader->gpuHandle) throw eve::Exception("DepthOfField: failed to create shader"); + shader->declareFloat("texelW"); + shader->declareFloat("texelH"); + shader->declareFloat("directionX"); + shader->declareFloat("directionY"); + shader->declareFloat("focusDistance"); + shader->declareFloat("focusRange"); + shader->declareFloat("maxBlurPx"); + shader->declareFloat("nearZ"); + shader->declareFloat("farZ"); + return shader; +} + +} // namespace + +DepthOfField::DepthOfField(Graphics *gfx) : gfx_(gfx) { + if (!gfx_) throw eve::Exception("DepthOfField: null graphics"); + shader_ = newDofShader(gfx_); +} + +void DepthOfField::ensureTargets(int width, int height) { + width = std::max(width, 1); + height = std::max(height, 1); + if (temp_ && width_ == width && height_ == height) return; + width_ = width; + height_ = height; + temp_ = gfx_->newHDRCanvas(width, height); + output_ = gfx_->newHDRCanvas(width, height); + if (!temp_ || !output_) throw eve::Exception("DepthOfField: failed to allocate HDR targets"); +} + +void DepthOfField::configure(float dirX, float dirY, float focusDistance, float maxBlurPx, + float focusRange, float nearZ, float farZ, int width, int height) { + shader_->sendFloat("texelW", 1.f / float(std::max(width, 1))); + shader_->sendFloat("texelH", 1.f / float(std::max(height, 1))); + shader_->sendFloat("directionX", dirX); + shader_->sendFloat("directionY", dirY); + shader_->sendFloat("focusDistance", std::max(focusDistance, 0.f)); + shader_->sendFloat("focusRange", std::max(focusRange, 1e-3f)); + shader_->sendFloat("maxBlurPx", std::max(maxBlurPx, 0.f)); + const float nearClip = nearZ > 1e-4f ? nearZ : 0.1f; + const float farClip = farZ > nearClip ? farZ : nearClip + 1.f; + shader_->sendFloat("nearZ", nearClip); + shader_->sendFloat("farZ", farClip); +} + +Texture *DepthOfField::apply(Texture *source, Texture *hwDepth, float focusDistance, + float maxBlurPx, float focusRange, float nearZ, float farZ) { + if (!source) throw eve::Exception("DepthOfField.apply: null source"); + if (!hwDepth || maxBlurPx <= 0.f) return source; + + ensureTargets(source->getWidth(), source->getHeight()); + Canvas *previous = gfx_->getCanvas(); + + temp_->clear(Color(0.f, 0.f, 0.f, 0.f), {}, {}); + gfx_->setCanvas(temp_); + configure(1.f, 0.f, focusDistance, maxBlurPx, focusRange, nearZ, farZ, width_, height_); + gfx_->drawTexturedRectShaderDepth(source, hwDepth, shader_, 0.f, 0.f, float(width_), + float(height_), Color(1.f, 1.f, 1.f, 1.f)); + + output_->clear(Color(0.f, 0.f, 0.f, 0.f), {}, {}); + gfx_->setCanvas(output_); + configure(0.f, 1.f, focusDistance, maxBlurPx, focusRange, nearZ, farZ, width_, height_); + gfx_->drawTexturedRectShaderDepth(temp_->getTexture(), hwDepth, shader_, 0.f, 0.f, float(width_), + float(height_), Color(1.f, 1.f, 1.f, 1.f)); + + gfx_->setCanvas(previous); + Texture *result = output_->getTexture(); + if (!result) throw eve::Exception("DepthOfField: output texture missing"); + return result; +} + +} // namespace eve::graphics diff --git a/src/modules/graphics/DepthOfField.h b/src/modules/graphics/DepthOfField.h new file mode 100644 index 00000000..6b222b30 --- /dev/null +++ b/src/modules/graphics/DepthOfField.h @@ -0,0 +1,51 @@ +#pragma once + +namespace eve::graphics { + +class Canvas; +class Graphics; +class Shader; +class Texture; + +/** + * @brief Separable Gaussian depth-of-field for the final HDR scene. + * + * Reads linear HDR color plus hardware depth (Vulkan NDC z). Blur radius grows + * with view-space distance from `focusDistance` over `focusRange`, capped at + * `maxBlurPx`. Zero max blur is a no-op. Used by HD-2D miniature looks; sprites + * must write depth (masked/cutout) so characters stay sharp in the focus band. + */ +class DepthOfField { +public: + /** @brief Create the cross-backend DOF shaders. */ + explicit DepthOfField(Graphics *gfx); + + /** + * @brief Apply separable Gaussian DOF. + * @param source Linear HDR scene color. + * @param hwDepth Hardware depth (NDC z in .r). Null disables the effect. + * @param focusDistance View-space focus plane distance. + * @param maxBlurPx Maximum blur radius in source texels; <= 0 disables. + * @param focusRange Distance from focus at which blur reaches maxBlurPx. + * @param nearZ Camera near clip. + * @param farZ Camera far clip. + * @return Blurred HDR texture owned by this DepthOfField, or `source` when disabled. + * @lifetime Returned texture remains valid until dimensions change or this object is destroyed. + */ + Texture *apply(Texture *source, Texture *hwDepth, float focusDistance, float maxBlurPx, + float focusRange, float nearZ, float farZ); + +private: + void ensureTargets(int width, int height); + void configure(float dirX, float dirY, float focusDistance, float maxBlurPx, float focusRange, + float nearZ, float farZ, int width, int height); + + Graphics *gfx_ = nullptr; + Shader *shader_ = nullptr; + Canvas *temp_ = nullptr; + Canvas *output_ = nullptr; + int width_ = 0; + int height_ = 0; +}; + +} // namespace eve::graphics diff --git a/src/modules/graphics/Graphics.cpp b/src/modules/graphics/Graphics.cpp index 675d0ccb..b1a5b851 100644 --- a/src/modules/graphics/Graphics.cpp +++ b/src/modules/graphics/Graphics.cpp @@ -1,5 +1,6 @@ #include "graphics/Graphics.h" #include "graphics/Bloom.h" +#include "graphics/DepthOfField.h" #include "graphics/Exposure.h" #include "graphics/DepthPyramid.h" #include "common/Capability.h" @@ -22,6 +23,7 @@ #include "graphics/CanvasScriptBindings.h" #include "graphics/FogVolume.h" #include "graphics/Font.h" +#include "graphics/GBuffer.h" #include "graphics/GlobalIllumination.h" #include "graphics/Light.h" #include "graphics/MapFog.h" @@ -418,6 +420,10 @@ void Graphics::expose(ssq::Table& table) { cam.addFunc("setBloom", &Camera3D::setBloom); cam.addFunc("getBloomIntensity", &Camera3D::getBloomIntensity); cam.addFunc("getBloomThreshold", &Camera3D::getBloomThreshold); + cam.addFunc("setDepthOfField", &Camera3D::setDepthOfField); + cam.addFunc("getDofFocusDistance", &Camera3D::getDofFocusDistance); + cam.addFunc("getDofMaxBlur", &Camera3D::getDofMaxBlur); + cam.addFunc("getDofFocusRange", &Camera3D::getDofFocusRange); cam.addFunc("setEnvProbe", &Camera3D::setEnvProbe); cam.addFunc("clearEnvProbe", &Camera3D::clearEnvProbe); cam.addFunc("hasEnvProbe", &Camera3D::hasEnvProbe); @@ -1183,6 +1189,11 @@ Bloom* Graphics::pipelineBloom() { return pipelineBloom_.get(); } +DepthOfField* Graphics::pipelineDepthOfField() { + if (!pipelineDof_) pipelineDof_ = std::make_unique(this); + return pipelineDof_.get(); +} + Exposure* Graphics::pipelineExposure() { if (!pipelineExposure_) pipelineExposure_ = std::make_unique(this); return pipelineExposure_.get(); @@ -1218,6 +1229,18 @@ Texture* Graphics::prepareFinalSceneTexture(Texture *scene, Texture *motion) { aa->applyTo(this, scene, spatialAAResolve_); resolved = spatialAAResolve_->getTexture(); } + if (getSceneDofMaxBlur() > 0.f && renderControl_) { + GBuffer *gb = renderControl_->getGBuffer(); + if (gb && gb->isValid()) { + Texture *depth = getBackendName() == "webgpu" ? gb->getDepthTexture() + : gb->getHwDepthTexture(); + if (depth) { + resolved = pipelineDepthOfField()->apply( + resolved, depth, getSceneDofFocusDistance(), getSceneDofMaxBlur(), + getSceneDofFocusRange(), getSceneDofNearZ(), getSceneDofFarZ()); + } + } + } Texture *meterSource = resolved; resolved = pipelineBloom()->apply(resolved, getSceneBloomIntensity(), getSceneBloomThreshold()); diff --git a/src/modules/graphics/Graphics.h b/src/modules/graphics/Graphics.h index be900462..c6b5bf44 100644 --- a/src/modules/graphics/Graphics.h +++ b/src/modules/graphics/Graphics.h @@ -43,6 +43,7 @@ struct ShaderResourceInputs; class AmbientOcclusion; class AntiAliasing; class Bloom; +class DepthOfField; class Exposure; class DepthPyramid; class Camera3D; @@ -1217,6 +1218,21 @@ public: virtual float getSceneBloomIntensity() const = 0; /** @brief Current linear HDR bloom threshold. */ virtual float getSceneBloomThreshold() const = 0; + /** + * @brief Configure final-scene Gaussian depth-of-field. + * @param focusDistance View-space focus plane. + * @param maxBlurPx Max blur radius in texels; <= 0 disables. + * @param focusRange Distance from focus where blur reaches the max. + * @param nearZ Camera near clip used to linearize hardware depth. + * @param farZ Camera far clip used to linearize hardware depth. + */ + virtual void setSceneDepthOfField(float focusDistance, float maxBlurPx, float focusRange, + float nearZ, float farZ) = 0; + virtual float getSceneDofFocusDistance() const = 0; + virtual float getSceneDofMaxBlur() const = 0; + virtual float getSceneDofFocusRange() const = 0; + virtual float getSceneDofNearZ() const = 0; + virtual float getSceneDofFarZ() const = 0; /** @brief Upload CSM constants for subsequent default mesh draws (active=false disables). */ virtual void setMesh3DShadows(const ShadowUpload &upload) = 0; @@ -1724,13 +1740,16 @@ public: /** @brief Pipeline-owned linear-HDR bloom pyramid, created on first use. * @lifetime Returned effect remains valid until Graphics shutdown. */ Bloom *pipelineBloom(); + /** @brief Pipeline-owned Gaussian depth-of-field, created on first use. + * @lifetime Returned effect remains valid until Graphics shutdown. */ + DepthOfField *pipelineDepthOfField(); /** @brief Pipeline-owned GPU exposure metering and eye adaptation. * @lifetime Returned effect remains valid until Graphics shutdown. */ Exposure *pipelineExposure(); /** @brief Pipeline-owned shared min/max depth hierarchy for screen-space effects. * @lifetime Returned effect remains valid until Graphics shutdown. */ DepthPyramid *pipelineDepthPyramid(); - /** @brief Build the shared linear-HDR AA, bloom, and exposure result for final ACES. + /** @brief Build the shared linear-HDR AA, DOF, bloom, and exposure result for final ACES. * @lifetime Returned texture is Graphics-owned and valid for the current target allocation. */ Texture *prepareFinalSceneTexture(Texture *scene, Texture *motion = nullptr); /** @brief Return a reusable HDR target for composing screen-space lighting. @@ -1910,6 +1929,7 @@ protected: std::unique_ptr pipelineSSR_; std::unique_ptr pipelineAA_; std::unique_ptr pipelineBloom_; + std::unique_ptr pipelineDof_; std::unique_ptr pipelineExposure_; std::unique_ptr pipelineDepthPyramid_; Canvas *spatialAAResolve_ = nullptr; diff --git a/src/modules/graphics/RenderSystem3D.cpp b/src/modules/graphics/RenderSystem3D.cpp index 8bc59d1d..2e7d5e8d 100644 --- a/src/modules/graphics/RenderSystem3D.cpp +++ b/src/modules/graphics/RenderSystem3D.cpp @@ -233,6 +233,17 @@ void Camera3D::setBloom(float intensity, float threshold) { float Camera3D::getBloomIntensity() { return data()->bloomIntensity; } float Camera3D::getBloomThreshold() { return data()->bloomThreshold; } +void Camera3D::setDepthOfField(float focusDistance, float maxBlurPx, float focusRange) { + auto d = data(); + d->dofFocusDistance = std::max(0.f, focusDistance); + d->dofMaxBlurPx = std::clamp(maxBlurPx, 0.f, 32.f); + d->dofFocusRange = std::max(1e-3f, focusRange); +} + +float Camera3D::getDofFocusDistance() { return data()->dofFocusDistance; } +float Camera3D::getDofMaxBlur() { return data()->dofMaxBlurPx; } +float Camera3D::getDofFocusRange() { return data()->dofFocusRange; } + void Camera3D::setEnvProbe(float centerX, float centerY, float centerZ, float extentX, float extentY, float extentZ) { data()->envProbeCenter = glm::vec3(centerX, centerY, centerZ); @@ -857,6 +868,8 @@ void RenderSystem3D::render(Graphics &gfx) { gfx.setSceneAutoExposure(cd->autoExposure, cd->autoExposureMinEV, cd->autoExposureMaxEV); gfx.setSceneBloom(cd->bloomIntensity, cd->bloomThreshold); + gfx.setSceneDepthOfField(cd->dofFocusDistance, cd->dofMaxBlurPx, cd->dofFocusRange, + cd->nearZ, cd->farZ); } gfx.begin3DFrame(); if (!gfx.had3DThisFrame()) return; @@ -1330,6 +1343,8 @@ void RenderSystem3D::renderToCanvas(Graphics &gfx, Canvas *target, Camera3D *cam gfx.setSceneAutoExposure(cd->autoExposure, cd->autoExposureMinEV, cd->autoExposureMaxEV); gfx.setSceneBloom(cd->bloomIntensity, cd->bloomThreshold); + gfx.setSceneDepthOfField(cd->dofFocusDistance, cd->dofMaxBlurPx, cd->dofFocusRange, cd->nearZ, + cd->farZ); // Lighting: real lights (if any) + camera ambient; shadows/clustered off. std::vector packed; diff --git a/src/modules/graphics/RenderSystem3D.h b/src/modules/graphics/RenderSystem3D.h index 7a6f1508..77f2b40f 100644 --- a/src/modules/graphics/RenderSystem3D.h +++ b/src/modules/graphics/RenderSystem3D.h @@ -55,6 +55,9 @@ public: float autoExposureMaxEV = 8.f; float bloomIntensity = 0.f; float bloomThreshold = 1.f; + float dofFocusDistance = 0.f; + float dofMaxBlurPx = 0.f; // <= 0 disables DOF + float dofFocusRange = 8.f; bool active = false; Camera3D *entity = nullptr; // Last screenToRay() result (origin = eye, dir normalized). @@ -111,6 +114,19 @@ public: float getBloomIntensity(); /** @brief Return bloom threshold in linear HDR units. */ float getBloomThreshold(); + /** + * @brief Configure Gaussian depth-of-field on the final HDR resolve. + * @param focusDistance View-space distance of the sharp plane. + * @param maxBlurPx Maximum blur radius in texels; <= 0 disables DOF. + * @param focusRange Distance from focus at which blur reaches maxBlurPx. + */ + void setDepthOfField(float focusDistance, float maxBlurPx, float focusRange = 8.f); + /** @brief Return DOF focus distance. */ + float getDofFocusDistance(); + /** @brief Return DOF max blur in texels (0 when disabled). */ + float getDofMaxBlur(); + /** @brief Return DOF focus range. */ + float getDofFocusRange(); /** @brief Enable box-projected IBL for the camera environment cubemap. */ void setEnvProbe(float centerX, float centerY, float centerZ, float extentX, float extentY, float extentZ); diff --git a/src/modules/graphics/shaders/PostProcessWgsl.h b/src/modules/graphics/shaders/PostProcessWgsl.h index 514cda32..d69f5db2 100644 --- a/src/modules/graphics/shaders/PostProcessWgsl.h +++ b/src/modules/graphics/shaders/PostProcessWgsl.h @@ -122,4 +122,21 @@ fn viewz(z:f32)->f32{let n=max(p(10),.0001);let f=max(p(11),n+.001);return n*f/m let nt=clamp(p(3),0,2);let edge=max(de,smoothstep(nt*(1-clamp(p(4),0,1)),nt+clamp(p(4),0,1),ne));return vec4f(color,clamp(edge,0,1))*i.color;} )wgsl"; +/** @lifetime Shader source has static storage for the process lifetime. */ +inline constexpr const char *kDofGaussian = R"wgsl( +fn viewz(z:f32)->f32{let n=max(p(7),.0001);let f=max(p(8),n+.001);return n*f/max(f-z*(f-n),.000001);} +fn coc(viewZ:f32)->f32{let focus=max(p(4),0);let range=max(p(5),.001);let maxBlur=max(p(6),0);var t=clamp(abs(viewZ-focus)/range,0,1);t=t*t*(3-2*t);return t*maxBlur;} +@fragment fn fs_main(i:FSIn)->@location(0) vec4f{ + let maxBlur=max(p(6),0);if maxBlur<=.0001{return tex(i.uv)*i.color;} + let ndc=aux_load(i.uv).r;let vz=viewz(ndc);let radius=coc(vz); + if radius<.35||ndc>=.9999{return tex(i.uv)*i.color;} + let stepUv=vec2f(p(2),p(3))*vec2f(p(0),p(1))*radius; + let kw=array(.227027,.1945946,.1216216,.054054,.016216); + var color=tex(i.uv).rgb*kw[0];var weight=kw[0]; + for(var s=1;s<5;s++){let off=stepUv*f32(s);let uvA=clamp(i.uv+off,vec2f(0),vec2f(1));let uvB=clamp(i.uv-off,vec2f(0),vec2f(1)); + let wA=kw[s]*select(0.0,1.0,coc(viewz(aux_load(uvA).r))>=radius*.35);let wB=kw[s]*select(0.0,1.0,coc(viewz(aux_load(uvB).r))>=radius*.35); + color+=tex(uvA).rgb*wA;color+=tex(uvB).rgb*wB;weight+=wA+wB;} + return vec4f(color/max(weight,.0001),1)*i.color;} +)wgsl"; + } // namespace eve::graphics::shaders diff --git a/src/modules/graphics/shaders/dof_gaussian.frag b/src/modules/graphics/shaders/dof_gaussian.frag new file mode 100644 index 00000000..665aedc5 --- /dev/null +++ b/src/modules/graphics/shaders/dof_gaussian.frag @@ -0,0 +1,72 @@ +#version 450 +// Separable Gaussian depth-of-field for HD-2D miniature look. +// +// MainTex (binding 0) = linear HDR scene color. +// DepthTex(binding 1) = hardware D32 depth, .r = Vulkan NDC z (0 near, 1 far). +// +// Push constants (declareFloat order): +// 0 texelW 1 texelH 2 directionX 3 directionY +// 4 focusDistance 5 focusRange 6 maxBlurPx +// 7 nearZ 8 farZ + +layout(location = 0) in vec4 fragColor; +layout(location = 1) in vec2 fragUV; +layout(location = 0) out vec4 outColor; +layout(binding = 0) uniform sampler2D MainTex; +layout(binding = 1) uniform sampler2D DepthTex; +layout(push_constant) uniform Externals { float data[32]; } u; + +float toViewZ(float ndcZ) { + float nearZ = max(u.data[7], 1e-4); + float farZ = max(u.data[8], nearZ + 1e-3); + return (nearZ * farZ) / max(farZ - ndcZ * (farZ - nearZ), 1e-6); +} + +float cocRadius(float viewZ) { + float focus = max(u.data[4], 0.0); + float range = max(u.data[5], 1e-3); + float maxBlur = max(u.data[6], 0.0); + float t = clamp(abs(viewZ - focus) / range, 0.0, 1.0); + // Smoothstep keeps the in-focus band readable for pixel characters. + t = t * t * (3.0 - 2.0 * t); + return t * maxBlur; +} + +void main() { + float maxBlur = max(u.data[6], 0.0); + if (maxBlur <= 1e-4) { + outColor = texture(MainTex, fragUV) * fragColor; + return; + } + + float ndcZ = texture(DepthTex, fragUV).r; + float viewZ = toViewZ(ndcZ); + float radius = cocRadius(viewZ); + if (radius < 0.35 || ndcZ >= 0.9999) { + outColor = texture(MainTex, fragUV) * fragColor; + return; + } + + vec2 dir = vec2(u.data[2], u.data[3]); + vec2 stepUv = dir * vec2(u.data[0], u.data[1]) * radius; + + // 9-tap Gaussian (sigma ~= 2) along the separable axis. + const float kW[5] = float[5](0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216); + vec3 color = texture(MainTex, fragUV).rgb * kW[0]; + float weight = kW[0]; + for (int i = 1; i < 5; ++i) { + float fi = float(i); + vec2 off = stepUv * fi; + vec2 uvA = clamp(fragUV + off, vec2(0.0), vec2(1.0)); + vec2 uvB = clamp(fragUV - off, vec2(0.0), vec2(1.0)); + float zA = toViewZ(texture(DepthTex, uvA).r); + float zB = toViewZ(texture(DepthTex, uvB).r); + // Reject samples whose CoC is much smaller (keeps sharp sprites from bleeding). + float wA = kW[i] * step(radius * 0.35, max(cocRadius(zA), 0.35)); + float wB = kW[i] * step(radius * 0.35, max(cocRadius(zB), 0.35)); + color += texture(MainTex, uvA).rgb * wA; + color += texture(MainTex, uvB).rgb * wB; + weight += wA + wB; + } + outColor = vec4(color / max(weight, 1e-4), 1.0) * fragColor; +} diff --git a/src/modules/graphics/shaders/dof_gaussian_frag_spv.inc b/src/modules/graphics/shaders/dof_gaussian_frag_spv.inc new file mode 100644 index 00000000..da1e67fc --- /dev/null +++ b/src/modules/graphics/shaders/dof_gaussian_frag_spv.inc @@ -0,0 +1,213 @@ +static const uint32_t dof_gaussian_frag_spv[] = { + 0x07230203, 0x00010000, 0x000d000b, 0x00000121, 0x00000000, 0x00020011, 0x00000001, 0x0006000b, + 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e, 0x00000000, 0x0003000e, 0x00000000, 0x00000001, + 0x0008000f, 0x00000004, 0x00000004, 0x6e69616d, 0x00000000, 0x00000067, 0x0000006f, 0x00000073, + 0x00030010, 0x00000004, 0x00000007, 0x00030003, 0x00000002, 0x000001c2, 0x000a0004, 0x475f4c47, + 0x4c474f4f, 0x70635f45, 0x74735f70, 0x5f656c79, 0x656e696c, 0x7269645f, 0x69746365, 0x00006576, + 0x00080004, 0x475f4c47, 0x4c474f4f, 0x6e695f45, 0x64756c63, 0x69645f65, 0x74636572, 0x00657669, + 0x00040005, 0x00000004, 0x6e69616d, 0x00000000, 0x00050005, 0x0000000a, 0x69566f74, 0x285a7765, + 0x003b3166, 0x00040005, 0x00000009, 0x5a63646e, 0x00000000, 0x00060005, 0x0000000d, 0x52636f63, + 0x75696461, 0x31662873, 0x0000003b, 0x00040005, 0x0000000c, 0x77656976, 0x0000005a, 0x00040005, + 0x0000000f, 0x7261656e, 0x0000005a, 0x00050005, 0x00000013, 0x65747845, 0x6c616e72, 0x00000073, + 0x00050006, 0x00000013, 0x00000000, 0x61746164, 0x00000000, 0x00030005, 0x00000015, 0x00000075, + 0x00040005, 0x0000001e, 0x5a726166, 0x00000000, 0x00040005, 0x00000035, 0x75636f66, 0x00000073, + 0x00040005, 0x0000003b, 0x676e6172, 0x00000065, 0x00040005, 0x00000040, 0x4278616d, 0x0072756c, + 0x00030005, 0x00000045, 0x00000074, 0x00040005, 0x0000005c, 0x4278616d, 0x0072756c, 0x00050005, + 0x00000067, 0x4374756f, 0x726f6c6f, 0x00000000, 0x00040005, 0x0000006b, 0x6e69614d, 0x00786554, + 0x00040005, 0x0000006f, 0x67617266, 0x00005655, 0x00050005, 0x00000073, 0x67617266, 0x6f6c6f43, + 0x00000072, 0x00040005, 0x00000077, 0x5a63646e, 0x00000000, 0x00050005, 0x00000078, 0x74706544, + 0x78655468, 0x00000000, 0x00040005, 0x0000007e, 0x77656976, 0x0000005a, 0x00040005, 0x0000007f, + 0x61726170, 0x0000006d, 0x00040005, 0x00000082, 0x69646172, 0x00007375, 0x00040005, 0x00000083, + 0x61726170, 0x0000006d, 0x00030005, 0x00000096, 0x00726964, 0x00040005, 0x0000009e, 0x70657473, + 0x00007655, 0x00040005, 0x000000ab, 0x6f6c6f63, 0x00000072, 0x00040005, 0x000000b2, 0x67696577, + 0x00007468, 0x00030005, 0x000000b4, 0x00000069, 0x00030005, 0x000000bc, 0x00006966, 0x00030005, + 0x000000bf, 0x0066666f, 0x00030005, 0x000000c3, 0x00417675, 0x00030005, 0x000000ca, 0x00427675, + 0x00030005, 0x000000cf, 0x0000417a, 0x00040005, 0x000000d3, 0x61726170, 0x0000006d, 0x00030005, + 0x000000d6, 0x0000427a, 0x00040005, 0x000000da, 0x61726170, 0x0000006d, 0x00030005, 0x000000dd, + 0x00004177, 0x00050005, 0x000000e7, 0x65646e69, 0x6c626178, 0x00000065, 0x00040005, 0x000000ec, + 0x61726170, 0x0000006d, 0x00030005, 0x000000f2, 0x00004277, 0x00050005, 0x000000f4, 0x65646e69, + 0x6c626178, 0x00000065, 0x00040005, 0x000000f9, 0x61726170, 0x0000006d, 0x00040047, 0x00000012, + 0x00000006, 0x00000004, 0x00050048, 0x00000013, 0x00000000, 0x00000023, 0x00000000, 0x00030047, + 0x00000013, 0x00000002, 0x00040047, 0x00000067, 0x0000001e, 0x00000000, 0x00040047, 0x0000006b, + 0x00000022, 0x00000000, 0x00040047, 0x0000006b, 0x00000021, 0x00000000, 0x00040047, 0x0000006f, + 0x0000001e, 0x00000001, 0x00040047, 0x00000073, 0x0000001e, 0x00000000, 0x00040047, 0x00000078, + 0x00000022, 0x00000000, 0x00040047, 0x00000078, 0x00000021, 0x00000001, 0x00020013, 0x00000002, + 0x00030021, 0x00000003, 0x00000002, 0x00030016, 0x00000006, 0x00000020, 0x00040020, 0x00000007, + 0x00000007, 0x00000006, 0x00040021, 0x00000008, 0x00000006, 0x00000007, 0x00040015, 0x00000010, + 0x00000020, 0x00000000, 0x0004002b, 0x00000010, 0x00000011, 0x00000020, 0x0004001c, 0x00000012, + 0x00000006, 0x00000011, 0x0003001e, 0x00000013, 0x00000012, 0x00040020, 0x00000014, 0x00000009, + 0x00000013, 0x0004003b, 0x00000014, 0x00000015, 0x00000009, 0x00040015, 0x00000016, 0x00000020, + 0x00000001, 0x0004002b, 0x00000016, 0x00000017, 0x00000000, 0x0004002b, 0x00000016, 0x00000018, + 0x00000007, 0x00040020, 0x00000019, 0x00000009, 0x00000006, 0x0004002b, 0x00000006, 0x0000001c, + 0x38d1b717, 0x0004002b, 0x00000016, 0x0000001f, 0x00000008, 0x0004002b, 0x00000006, 0x00000023, + 0x3a83126f, 0x0004002b, 0x00000006, 0x00000030, 0x358637bd, 0x0004002b, 0x00000016, 0x00000036, + 0x00000004, 0x0004002b, 0x00000006, 0x00000039, 0x00000000, 0x0004002b, 0x00000016, 0x0000003c, + 0x00000005, 0x0004002b, 0x00000016, 0x00000041, 0x00000006, 0x0004002b, 0x00000006, 0x0000004c, + 0x3f800000, 0x0004002b, 0x00000006, 0x00000051, 0x40400000, 0x0004002b, 0x00000006, 0x00000052, + 0x40000000, 0x00020014, 0x00000061, 0x00040017, 0x00000065, 0x00000006, 0x00000004, 0x00040020, + 0x00000066, 0x00000003, 0x00000065, 0x0004003b, 0x00000066, 0x00000067, 0x00000003, 0x00090019, + 0x00000068, 0x00000006, 0x00000001, 0x00000000, 0x00000000, 0x00000000, 0x00000001, 0x00000000, + 0x0003001b, 0x00000069, 0x00000068, 0x00040020, 0x0000006a, 0x00000000, 0x00000069, 0x0004003b, + 0x0000006a, 0x0000006b, 0x00000000, 0x00040017, 0x0000006d, 0x00000006, 0x00000002, 0x00040020, + 0x0000006e, 0x00000001, 0x0000006d, 0x0004003b, 0x0000006e, 0x0000006f, 0x00000001, 0x00040020, + 0x00000072, 0x00000001, 0x00000065, 0x0004003b, 0x00000072, 0x00000073, 0x00000001, 0x0004003b, + 0x0000006a, 0x00000078, 0x00000000, 0x0004002b, 0x00000010, 0x0000007c, 0x00000000, 0x0004002b, + 0x00000006, 0x00000087, 0x3eb33333, 0x0004002b, 0x00000006, 0x0000008a, 0x3f7ff972, 0x00040020, + 0x00000095, 0x00000007, 0x0000006d, 0x0004002b, 0x00000016, 0x00000097, 0x00000002, 0x0004002b, + 0x00000016, 0x0000009a, 0x00000003, 0x0004002b, 0x00000016, 0x000000a2, 0x00000001, 0x00040017, + 0x000000a9, 0x00000006, 0x00000003, 0x00040020, 0x000000aa, 0x00000007, 0x000000a9, 0x0004002b, + 0x00000006, 0x000000b0, 0x3e6879c4, 0x00040020, 0x000000b3, 0x00000007, 0x00000016, 0x0005002c, + 0x0000006d, 0x000000c7, 0x00000039, 0x00000039, 0x0005002c, 0x0000006d, 0x000000c8, 0x0000004c, + 0x0000004c, 0x0004002b, 0x00000010, 0x000000de, 0x00000005, 0x0004001c, 0x000000df, 0x00000006, + 0x000000de, 0x0004002b, 0x00000006, 0x000000e0, 0x3e4743cf, 0x0004002b, 0x00000006, 0x000000e1, + 0x3df914bf, 0x0004002b, 0x00000006, 0x000000e2, 0x3d5d67ba, 0x0004002b, 0x00000006, 0x000000e3, + 0x3c84d76b, 0x0008002c, 0x000000df, 0x000000e4, 0x000000b0, 0x000000e0, 0x000000e1, 0x000000e2, + 0x000000e3, 0x00040020, 0x000000e6, 0x00000007, 0x000000df, 0x00050036, 0x00000002, 0x00000004, + 0x00000000, 0x00000003, 0x000200f8, 0x00000005, 0x0004003b, 0x00000007, 0x0000005c, 0x00000007, + 0x0004003b, 0x00000007, 0x00000077, 0x00000007, 0x0004003b, 0x00000007, 0x0000007e, 0x00000007, + 0x0004003b, 0x00000007, 0x0000007f, 0x00000007, 0x0004003b, 0x00000007, 0x00000082, 0x00000007, + 0x0004003b, 0x00000007, 0x00000083, 0x00000007, 0x0004003b, 0x00000095, 0x00000096, 0x00000007, + 0x0004003b, 0x00000095, 0x0000009e, 0x00000007, 0x0004003b, 0x000000aa, 0x000000ab, 0x00000007, + 0x0004003b, 0x00000007, 0x000000b2, 0x00000007, 0x0004003b, 0x000000b3, 0x000000b4, 0x00000007, + 0x0004003b, 0x00000007, 0x000000bc, 0x00000007, 0x0004003b, 0x00000095, 0x000000bf, 0x00000007, + 0x0004003b, 0x00000095, 0x000000c3, 0x00000007, 0x0004003b, 0x00000095, 0x000000ca, 0x00000007, + 0x0004003b, 0x00000007, 0x000000cf, 0x00000007, 0x0004003b, 0x00000007, 0x000000d3, 0x00000007, + 0x0004003b, 0x00000007, 0x000000d6, 0x00000007, 0x0004003b, 0x00000007, 0x000000da, 0x00000007, + 0x0004003b, 0x00000007, 0x000000dd, 0x00000007, 0x0004003b, 0x000000e6, 0x000000e7, 0x00000007, + 0x0004003b, 0x00000007, 0x000000ec, 0x00000007, 0x0004003b, 0x00000007, 0x000000f2, 0x00000007, + 0x0004003b, 0x000000e6, 0x000000f4, 0x00000007, 0x0004003b, 0x00000007, 0x000000f9, 0x00000007, + 0x00060041, 0x00000019, 0x0000005d, 0x00000015, 0x00000017, 0x00000041, 0x0004003d, 0x00000006, + 0x0000005e, 0x0000005d, 0x0007000c, 0x00000006, 0x0000005f, 0x00000001, 0x00000028, 0x0000005e, + 0x00000039, 0x0003003e, 0x0000005c, 0x0000005f, 0x0004003d, 0x00000006, 0x00000060, 0x0000005c, + 0x000500bc, 0x00000061, 0x00000062, 0x00000060, 0x0000001c, 0x000300f7, 0x00000064, 0x00000000, + 0x000400fa, 0x00000062, 0x00000063, 0x00000064, 0x000200f8, 0x00000063, 0x0004003d, 0x00000069, + 0x0000006c, 0x0000006b, 0x0004003d, 0x0000006d, 0x00000070, 0x0000006f, 0x00050057, 0x00000065, + 0x00000071, 0x0000006c, 0x00000070, 0x0004003d, 0x00000065, 0x00000074, 0x00000073, 0x00050085, + 0x00000065, 0x00000075, 0x00000071, 0x00000074, 0x0003003e, 0x00000067, 0x00000075, 0x000100fd, + 0x000200f8, 0x00000064, 0x0004003d, 0x00000069, 0x00000079, 0x00000078, 0x0004003d, 0x0000006d, + 0x0000007a, 0x0000006f, 0x00050057, 0x00000065, 0x0000007b, 0x00000079, 0x0000007a, 0x00050051, + 0x00000006, 0x0000007d, 0x0000007b, 0x00000000, 0x0003003e, 0x00000077, 0x0000007d, 0x0004003d, + 0x00000006, 0x00000080, 0x00000077, 0x0003003e, 0x0000007f, 0x00000080, 0x00050039, 0x00000006, + 0x00000081, 0x0000000a, 0x0000007f, 0x0003003e, 0x0000007e, 0x00000081, 0x0004003d, 0x00000006, + 0x00000084, 0x0000007e, 0x0003003e, 0x00000083, 0x00000084, 0x00050039, 0x00000006, 0x00000085, + 0x0000000d, 0x00000083, 0x0003003e, 0x00000082, 0x00000085, 0x0004003d, 0x00000006, 0x00000086, + 0x00000082, 0x000500b8, 0x00000061, 0x00000088, 0x00000086, 0x00000087, 0x0004003d, 0x00000006, + 0x00000089, 0x00000077, 0x000500be, 0x00000061, 0x0000008b, 0x00000089, 0x0000008a, 0x000500a6, + 0x00000061, 0x0000008c, 0x00000088, 0x0000008b, 0x000300f7, 0x0000008e, 0x00000000, 0x000400fa, + 0x0000008c, 0x0000008d, 0x0000008e, 0x000200f8, 0x0000008d, 0x0004003d, 0x00000069, 0x0000008f, + 0x0000006b, 0x0004003d, 0x0000006d, 0x00000090, 0x0000006f, 0x00050057, 0x00000065, 0x00000091, + 0x0000008f, 0x00000090, 0x0004003d, 0x00000065, 0x00000092, 0x00000073, 0x00050085, 0x00000065, + 0x00000093, 0x00000091, 0x00000092, 0x0003003e, 0x00000067, 0x00000093, 0x000100fd, 0x000200f8, + 0x0000008e, 0x00060041, 0x00000019, 0x00000098, 0x00000015, 0x00000017, 0x00000097, 0x0004003d, + 0x00000006, 0x00000099, 0x00000098, 0x00060041, 0x00000019, 0x0000009b, 0x00000015, 0x00000017, + 0x0000009a, 0x0004003d, 0x00000006, 0x0000009c, 0x0000009b, 0x00050050, 0x0000006d, 0x0000009d, + 0x00000099, 0x0000009c, 0x0003003e, 0x00000096, 0x0000009d, 0x0004003d, 0x0000006d, 0x0000009f, + 0x00000096, 0x00060041, 0x00000019, 0x000000a0, 0x00000015, 0x00000017, 0x00000017, 0x0004003d, + 0x00000006, 0x000000a1, 0x000000a0, 0x00060041, 0x00000019, 0x000000a3, 0x00000015, 0x00000017, + 0x000000a2, 0x0004003d, 0x00000006, 0x000000a4, 0x000000a3, 0x00050050, 0x0000006d, 0x000000a5, + 0x000000a1, 0x000000a4, 0x00050085, 0x0000006d, 0x000000a6, 0x0000009f, 0x000000a5, 0x0004003d, + 0x00000006, 0x000000a7, 0x00000082, 0x0005008e, 0x0000006d, 0x000000a8, 0x000000a6, 0x000000a7, + 0x0003003e, 0x0000009e, 0x000000a8, 0x0004003d, 0x00000069, 0x000000ac, 0x0000006b, 0x0004003d, + 0x0000006d, 0x000000ad, 0x0000006f, 0x00050057, 0x00000065, 0x000000ae, 0x000000ac, 0x000000ad, + 0x0008004f, 0x000000a9, 0x000000af, 0x000000ae, 0x000000ae, 0x00000000, 0x00000001, 0x00000002, + 0x0005008e, 0x000000a9, 0x000000b1, 0x000000af, 0x000000b0, 0x0003003e, 0x000000ab, 0x000000b1, + 0x0003003e, 0x000000b2, 0x000000b0, 0x0003003e, 0x000000b4, 0x000000a2, 0x000200f9, 0x000000b5, + 0x000200f8, 0x000000b5, 0x000400f6, 0x000000b7, 0x000000b8, 0x00000000, 0x000200f9, 0x000000b9, + 0x000200f8, 0x000000b9, 0x0004003d, 0x00000016, 0x000000ba, 0x000000b4, 0x000500b1, 0x00000061, + 0x000000bb, 0x000000ba, 0x0000003c, 0x000400fa, 0x000000bb, 0x000000b6, 0x000000b7, 0x000200f8, + 0x000000b6, 0x0004003d, 0x00000016, 0x000000bd, 0x000000b4, 0x0004006f, 0x00000006, 0x000000be, + 0x000000bd, 0x0003003e, 0x000000bc, 0x000000be, 0x0004003d, 0x0000006d, 0x000000c0, 0x0000009e, + 0x0004003d, 0x00000006, 0x000000c1, 0x000000bc, 0x0005008e, 0x0000006d, 0x000000c2, 0x000000c0, + 0x000000c1, 0x0003003e, 0x000000bf, 0x000000c2, 0x0004003d, 0x0000006d, 0x000000c4, 0x0000006f, + 0x0004003d, 0x0000006d, 0x000000c5, 0x000000bf, 0x00050081, 0x0000006d, 0x000000c6, 0x000000c4, + 0x000000c5, 0x0008000c, 0x0000006d, 0x000000c9, 0x00000001, 0x0000002b, 0x000000c6, 0x000000c7, + 0x000000c8, 0x0003003e, 0x000000c3, 0x000000c9, 0x0004003d, 0x0000006d, 0x000000cb, 0x0000006f, + 0x0004003d, 0x0000006d, 0x000000cc, 0x000000bf, 0x00050083, 0x0000006d, 0x000000cd, 0x000000cb, + 0x000000cc, 0x0008000c, 0x0000006d, 0x000000ce, 0x00000001, 0x0000002b, 0x000000cd, 0x000000c7, + 0x000000c8, 0x0003003e, 0x000000ca, 0x000000ce, 0x0004003d, 0x00000069, 0x000000d0, 0x00000078, + 0x0004003d, 0x0000006d, 0x000000d1, 0x000000c3, 0x00050057, 0x00000065, 0x000000d2, 0x000000d0, + 0x000000d1, 0x00050051, 0x00000006, 0x000000d4, 0x000000d2, 0x00000000, 0x0003003e, 0x000000d3, + 0x000000d4, 0x00050039, 0x00000006, 0x000000d5, 0x0000000a, 0x000000d3, 0x0003003e, 0x000000cf, + 0x000000d5, 0x0004003d, 0x00000069, 0x000000d7, 0x00000078, 0x0004003d, 0x0000006d, 0x000000d8, + 0x000000ca, 0x00050057, 0x00000065, 0x000000d9, 0x000000d7, 0x000000d8, 0x00050051, 0x00000006, + 0x000000db, 0x000000d9, 0x00000000, 0x0003003e, 0x000000da, 0x000000db, 0x00050039, 0x00000006, + 0x000000dc, 0x0000000a, 0x000000da, 0x0003003e, 0x000000d6, 0x000000dc, 0x0004003d, 0x00000016, + 0x000000e5, 0x000000b4, 0x0003003e, 0x000000e7, 0x000000e4, 0x00050041, 0x00000007, 0x000000e8, + 0x000000e7, 0x000000e5, 0x0004003d, 0x00000006, 0x000000e9, 0x000000e8, 0x0004003d, 0x00000006, + 0x000000ea, 0x00000082, 0x00050085, 0x00000006, 0x000000eb, 0x000000ea, 0x00000087, 0x0004003d, + 0x00000006, 0x000000ed, 0x000000cf, 0x0003003e, 0x000000ec, 0x000000ed, 0x00050039, 0x00000006, + 0x000000ee, 0x0000000d, 0x000000ec, 0x0007000c, 0x00000006, 0x000000ef, 0x00000001, 0x00000028, + 0x000000ee, 0x00000087, 0x0007000c, 0x00000006, 0x000000f0, 0x00000001, 0x00000030, 0x000000eb, + 0x000000ef, 0x00050085, 0x00000006, 0x000000f1, 0x000000e9, 0x000000f0, 0x0003003e, 0x000000dd, + 0x000000f1, 0x0004003d, 0x00000016, 0x000000f3, 0x000000b4, 0x0003003e, 0x000000f4, 0x000000e4, + 0x00050041, 0x00000007, 0x000000f5, 0x000000f4, 0x000000f3, 0x0004003d, 0x00000006, 0x000000f6, + 0x000000f5, 0x0004003d, 0x00000006, 0x000000f7, 0x00000082, 0x00050085, 0x00000006, 0x000000f8, + 0x000000f7, 0x00000087, 0x0004003d, 0x00000006, 0x000000fa, 0x000000d6, 0x0003003e, 0x000000f9, + 0x000000fa, 0x00050039, 0x00000006, 0x000000fb, 0x0000000d, 0x000000f9, 0x0007000c, 0x00000006, + 0x000000fc, 0x00000001, 0x00000028, 0x000000fb, 0x00000087, 0x0007000c, 0x00000006, 0x000000fd, + 0x00000001, 0x00000030, 0x000000f8, 0x000000fc, 0x00050085, 0x00000006, 0x000000fe, 0x000000f6, + 0x000000fd, 0x0003003e, 0x000000f2, 0x000000fe, 0x0004003d, 0x00000069, 0x000000ff, 0x0000006b, + 0x0004003d, 0x0000006d, 0x00000100, 0x000000c3, 0x00050057, 0x00000065, 0x00000101, 0x000000ff, + 0x00000100, 0x0008004f, 0x000000a9, 0x00000102, 0x00000101, 0x00000101, 0x00000000, 0x00000001, + 0x00000002, 0x0004003d, 0x00000006, 0x00000103, 0x000000dd, 0x0005008e, 0x000000a9, 0x00000104, + 0x00000102, 0x00000103, 0x0004003d, 0x000000a9, 0x00000105, 0x000000ab, 0x00050081, 0x000000a9, + 0x00000106, 0x00000105, 0x00000104, 0x0003003e, 0x000000ab, 0x00000106, 0x0004003d, 0x00000069, + 0x00000107, 0x0000006b, 0x0004003d, 0x0000006d, 0x00000108, 0x000000ca, 0x00050057, 0x00000065, + 0x00000109, 0x00000107, 0x00000108, 0x0008004f, 0x000000a9, 0x0000010a, 0x00000109, 0x00000109, + 0x00000000, 0x00000001, 0x00000002, 0x0004003d, 0x00000006, 0x0000010b, 0x000000f2, 0x0005008e, + 0x000000a9, 0x0000010c, 0x0000010a, 0x0000010b, 0x0004003d, 0x000000a9, 0x0000010d, 0x000000ab, + 0x00050081, 0x000000a9, 0x0000010e, 0x0000010d, 0x0000010c, 0x0003003e, 0x000000ab, 0x0000010e, + 0x0004003d, 0x00000006, 0x0000010f, 0x000000dd, 0x0004003d, 0x00000006, 0x00000110, 0x000000f2, + 0x00050081, 0x00000006, 0x00000111, 0x0000010f, 0x00000110, 0x0004003d, 0x00000006, 0x00000112, + 0x000000b2, 0x00050081, 0x00000006, 0x00000113, 0x00000112, 0x00000111, 0x0003003e, 0x000000b2, + 0x00000113, 0x000200f9, 0x000000b8, 0x000200f8, 0x000000b8, 0x0004003d, 0x00000016, 0x00000114, + 0x000000b4, 0x00050080, 0x00000016, 0x00000115, 0x00000114, 0x000000a2, 0x0003003e, 0x000000b4, + 0x00000115, 0x000200f9, 0x000000b5, 0x000200f8, 0x000000b7, 0x0004003d, 0x000000a9, 0x00000116, + 0x000000ab, 0x0004003d, 0x00000006, 0x00000117, 0x000000b2, 0x0007000c, 0x00000006, 0x00000118, + 0x00000001, 0x00000028, 0x00000117, 0x0000001c, 0x00060050, 0x000000a9, 0x00000119, 0x00000118, + 0x00000118, 0x00000118, 0x00050088, 0x000000a9, 0x0000011a, 0x00000116, 0x00000119, 0x00050051, + 0x00000006, 0x0000011b, 0x0000011a, 0x00000000, 0x00050051, 0x00000006, 0x0000011c, 0x0000011a, + 0x00000001, 0x00050051, 0x00000006, 0x0000011d, 0x0000011a, 0x00000002, 0x00070050, 0x00000065, + 0x0000011e, 0x0000011b, 0x0000011c, 0x0000011d, 0x0000004c, 0x0004003d, 0x00000065, 0x0000011f, + 0x00000073, 0x00050085, 0x00000065, 0x00000120, 0x0000011e, 0x0000011f, 0x0003003e, 0x00000067, + 0x00000120, 0x000100fd, 0x00010038, 0x00050036, 0x00000006, 0x0000000a, 0x00000000, 0x00000008, + 0x00030037, 0x00000007, 0x00000009, 0x000200f8, 0x0000000b, 0x0004003b, 0x00000007, 0x0000000f, + 0x00000007, 0x0004003b, 0x00000007, 0x0000001e, 0x00000007, 0x00060041, 0x00000019, 0x0000001a, + 0x00000015, 0x00000017, 0x00000018, 0x0004003d, 0x00000006, 0x0000001b, 0x0000001a, 0x0007000c, + 0x00000006, 0x0000001d, 0x00000001, 0x00000028, 0x0000001b, 0x0000001c, 0x0003003e, 0x0000000f, + 0x0000001d, 0x00060041, 0x00000019, 0x00000020, 0x00000015, 0x00000017, 0x0000001f, 0x0004003d, + 0x00000006, 0x00000021, 0x00000020, 0x0004003d, 0x00000006, 0x00000022, 0x0000000f, 0x00050081, + 0x00000006, 0x00000024, 0x00000022, 0x00000023, 0x0007000c, 0x00000006, 0x00000025, 0x00000001, + 0x00000028, 0x00000021, 0x00000024, 0x0003003e, 0x0000001e, 0x00000025, 0x0004003d, 0x00000006, + 0x00000026, 0x0000000f, 0x0004003d, 0x00000006, 0x00000027, 0x0000001e, 0x00050085, 0x00000006, + 0x00000028, 0x00000026, 0x00000027, 0x0004003d, 0x00000006, 0x00000029, 0x0000001e, 0x0004003d, + 0x00000006, 0x0000002a, 0x00000009, 0x0004003d, 0x00000006, 0x0000002b, 0x0000001e, 0x0004003d, + 0x00000006, 0x0000002c, 0x0000000f, 0x00050083, 0x00000006, 0x0000002d, 0x0000002b, 0x0000002c, + 0x00050085, 0x00000006, 0x0000002e, 0x0000002a, 0x0000002d, 0x00050083, 0x00000006, 0x0000002f, + 0x00000029, 0x0000002e, 0x0007000c, 0x00000006, 0x00000031, 0x00000001, 0x00000028, 0x0000002f, + 0x00000030, 0x00050088, 0x00000006, 0x00000032, 0x00000028, 0x00000031, 0x000200fe, 0x00000032, + 0x00010038, 0x00050036, 0x00000006, 0x0000000d, 0x00000000, 0x00000008, 0x00030037, 0x00000007, + 0x0000000c, 0x000200f8, 0x0000000e, 0x0004003b, 0x00000007, 0x00000035, 0x00000007, 0x0004003b, + 0x00000007, 0x0000003b, 0x00000007, 0x0004003b, 0x00000007, 0x00000040, 0x00000007, 0x0004003b, + 0x00000007, 0x00000045, 0x00000007, 0x00060041, 0x00000019, 0x00000037, 0x00000015, 0x00000017, + 0x00000036, 0x0004003d, 0x00000006, 0x00000038, 0x00000037, 0x0007000c, 0x00000006, 0x0000003a, + 0x00000001, 0x00000028, 0x00000038, 0x00000039, 0x0003003e, 0x00000035, 0x0000003a, 0x00060041, + 0x00000019, 0x0000003d, 0x00000015, 0x00000017, 0x0000003c, 0x0004003d, 0x00000006, 0x0000003e, + 0x0000003d, 0x0007000c, 0x00000006, 0x0000003f, 0x00000001, 0x00000028, 0x0000003e, 0x00000023, + 0x0003003e, 0x0000003b, 0x0000003f, 0x00060041, 0x00000019, 0x00000042, 0x00000015, 0x00000017, + 0x00000041, 0x0004003d, 0x00000006, 0x00000043, 0x00000042, 0x0007000c, 0x00000006, 0x00000044, + 0x00000001, 0x00000028, 0x00000043, 0x00000039, 0x0003003e, 0x00000040, 0x00000044, 0x0004003d, + 0x00000006, 0x00000046, 0x0000000c, 0x0004003d, 0x00000006, 0x00000047, 0x00000035, 0x00050083, + 0x00000006, 0x00000048, 0x00000046, 0x00000047, 0x0006000c, 0x00000006, 0x00000049, 0x00000001, + 0x00000004, 0x00000048, 0x0004003d, 0x00000006, 0x0000004a, 0x0000003b, 0x00050088, 0x00000006, + 0x0000004b, 0x00000049, 0x0000004a, 0x0008000c, 0x00000006, 0x0000004d, 0x00000001, 0x0000002b, + 0x0000004b, 0x00000039, 0x0000004c, 0x0003003e, 0x00000045, 0x0000004d, 0x0004003d, 0x00000006, + 0x0000004e, 0x00000045, 0x0004003d, 0x00000006, 0x0000004f, 0x00000045, 0x00050085, 0x00000006, + 0x00000050, 0x0000004e, 0x0000004f, 0x0004003d, 0x00000006, 0x00000053, 0x00000045, 0x00050085, + 0x00000006, 0x00000054, 0x00000052, 0x00000053, 0x00050083, 0x00000006, 0x00000055, 0x00000051, + 0x00000054, 0x00050085, 0x00000006, 0x00000056, 0x00000050, 0x00000055, 0x0003003e, 0x00000045, + 0x00000056, 0x0004003d, 0x00000006, 0x00000057, 0x00000045, 0x0004003d, 0x00000006, 0x00000058, + 0x00000040, 0x00050085, 0x00000006, 0x00000059, 0x00000057, 0x00000058, 0x000200fe, 0x00000059, + 0x00010038, +}; +static const size_t dof_gaussian_frag_spv_count = 1673; diff --git a/src/modules/graphics/vulkan/Graphics.h b/src/modules/graphics/vulkan/Graphics.h index c048b190..0f1c9841 100644 --- a/src/modules/graphics/vulkan/Graphics.h +++ b/src/modules/graphics/vulkan/Graphics.h @@ -609,6 +609,19 @@ public: } float getSceneBloomIntensity() const override { return sceneBloomIntensity; } float getSceneBloomThreshold() const override { return sceneBloomThreshold; } + void setSceneDepthOfField(float focusDistance, float maxBlurPx, float focusRange, float nearZ, + float farZ) override { + sceneDofFocusDistance = focusDistance; + sceneDofMaxBlurPx = maxBlurPx; + sceneDofFocusRange = focusRange; + sceneDofNearZ = nearZ; + sceneDofFarZ = farZ; + } + float getSceneDofFocusDistance() const override { return sceneDofFocusDistance; } + float getSceneDofMaxBlur() const override { return sceneDofMaxBlurPx; } + float getSceneDofFocusRange() const override { return sceneDofFocusRange; } + float getSceneDofNearZ() const override { return sceneDofNearZ; } + float getSceneDofFarZ() const override { return sceneDofFarZ; } void setMesh3DShadows(const ShadowUpload &upload) override; void setMesh3DShadowReceive(bool receive) override; void beginShadowPass(int cascadeIndex) override; @@ -945,6 +958,11 @@ private: float sceneAutoExposureMaxEV = 8.f; float sceneBloomIntensity = 0.f; float sceneBloomThreshold = 1.f; + float sceneDofFocusDistance = 0.f; + float sceneDofMaxBlurPx = 0.f; + float sceneDofFocusRange = 8.f; + float sceneDofNearZ = 0.1f; + float sceneDofFarZ = 100.f; vk::PipelineLayout texPipelineLayout; vk::PipelineLayout shaderPipelineLayout; // tex set + push constants vk::CommandPool uploadPool; diff --git a/src/modules/graphics/webgpu/Graphics.h b/src/modules/graphics/webgpu/Graphics.h index d4b9f6af..99e26d36 100644 --- a/src/modules/graphics/webgpu/Graphics.h +++ b/src/modules/graphics/webgpu/Graphics.h @@ -408,6 +408,19 @@ public: } float getSceneBloomIntensity() const override { return sceneBloomIntensity; } float getSceneBloomThreshold() const override { return sceneBloomThreshold; } + void setSceneDepthOfField(float focusDistance, float maxBlurPx, float focusRange, float nearZ, + float farZ) override { + sceneDofFocusDistance = focusDistance; + sceneDofMaxBlurPx = maxBlurPx; + sceneDofFocusRange = focusRange; + sceneDofNearZ = nearZ; + sceneDofFarZ = farZ; + } + float getSceneDofFocusDistance() const override { return sceneDofFocusDistance; } + float getSceneDofMaxBlur() const override { return sceneDofMaxBlurPx; } + float getSceneDofFocusRange() const override { return sceneDofFocusRange; } + float getSceneDofNearZ() const override { return sceneDofNearZ; } + float getSceneDofFarZ() const override { return sceneDofFarZ; } void setMesh3DShadows(const ShadowUpload &upload) override; void setMesh3DShadowReceive(bool receive) override; void beginShadowPass(int cascadeIndex) override; @@ -800,6 +813,11 @@ private: float sceneAutoExposureMaxEV = 8.f; float sceneBloomIntensity = 0.f; float sceneBloomThreshold = 1.f; + float sceneDofFocusDistance = 0.f; + float sceneDofMaxBlurPx = 0.f; + float sceneDofFocusRange = 8.f; + float sceneDofNearZ = 0.1f; + float sceneDofFarZ = 100.f; wgpu::RenderPipeline mesh3dPipeline; wgpu::RenderPipeline mesh3dTransparentPipeline; static constexpr size_t kMeshPipelineVariants = 20; diff --git a/src/modules/hd2d/Hd2d.cpp b/src/modules/hd2d/Hd2d.cpp index 43097f58..329cc633 100644 --- a/src/modules/hd2d/Hd2d.cpp +++ b/src/modules/hd2d/Hd2d.cpp @@ -22,6 +22,10 @@ Sprite3D *Hd2D::newSprite(graphics::Graphics *gfx) { return sprite; } +Hd2dLook *Hd2D::newLook() { return new Hd2dLook(); } + +Hd2dLook *Hd2D::newMiniatureLook() { return new Hd2dLook(Hd2dLook::miniature()); } + void Hd2D::expose(ssq::Table &table) { auto cls = table.addClass(name, Hd2D::create, false); expose(cls); @@ -67,12 +71,37 @@ void Hd2D::expose(ssq::Table &table) { sprite.addFunc("setTint", &Sprite3D::setTint); sprite.addFunc("setVisible", &Sprite3D::setVisible); sprite.addFunc("getVisible", &Sprite3D::getVisible); + sprite.addFunc("setAlphaCutoff", &Sprite3D::setAlphaCutoff); + sprite.addFunc("getAlphaCutoff", &Sprite3D::getAlphaCutoff); + sprite.addFunc("setDepthWrite", &Sprite3D::setDepthWrite); + sprite.addFunc("getDepthWrite", &Sprite3D::getDepthWrite); + sprite.addFunc("setDoubleSided", &Sprite3D::setDoubleSided); + sprite.addFunc("getDoubleSided", &Sprite3D::getDoubleSided); + sprite.addFunc("setBillboardMode", &Sprite3D::setBillboardMode); + sprite.addFunc("getBillboardMode", &Sprite3D::getBillboardMode); + + auto look = table.addClass( + "Hd2dLook", std::function([]() { return nullptr; }), true); + look.addFunc("setFocusDistance", &Hd2dLook::setFocusDistance); + look.addFunc("getFocusDistance", &Hd2dLook::getFocusDistance); + look.addFunc("setMaxBlur", &Hd2dLook::setMaxBlur); + look.addFunc("getMaxBlur", &Hd2dLook::getMaxBlur); + look.addFunc("setFocusRange", &Hd2dLook::setFocusRange); + look.addFunc("getFocusRange", &Hd2dLook::getFocusRange); + look.addFunc("setBloomIntensity", &Hd2dLook::setBloomIntensity); + look.addFunc("getBloomIntensity", &Hd2dLook::getBloomIntensity); + look.addFunc("setBloomThreshold", &Hd2dLook::setBloomThreshold); + look.addFunc("getBloomThreshold", &Hd2dLook::getBloomThreshold); + look.addFunc("apply", &Hd2dLook::apply); + look.addFunc("applyPixelSampler", &Hd2dLook::applyPixelSampler); } void Hd2D::expose(ssq::Class &cls) { cls.addFunc("getName", &Hd2D::getName); cls.addFunc("newTileMap3D", &Hd2D::newTileMap3D); cls.addFunc("newSprite", &Hd2D::newSprite); + cls.addFunc("newLook", &Hd2D::newLook); + cls.addFunc("newMiniatureLook", &Hd2D::newMiniatureLook); } } // namespace eve::hd2d diff --git a/src/modules/hd2d/Hd2d.h b/src/modules/hd2d/Hd2d.h index 4a47c4ec..65acee17 100644 --- a/src/modules/hd2d/Hd2d.h +++ b/src/modules/hd2d/Hd2d.h @@ -195,6 +195,28 @@ public: void setVisible(bool visible); bool getVisible() const; + /** + * @brief Alpha cutout threshold for the masked material (DopFix-style depth write). + * @param cutoff Finite value clamped to [0,1]; default 0.5. + */ + void setAlphaCutoff(float cutoff); + float getAlphaCutoff() const; + /** + * @brief Whether opaque/cutout texels write depth (required for DOF focus). + * Default true — matches HD2DURP DopFix / TransparentCutout materials. + */ + void setDepthWrite(bool enabled); + bool getDepthWrite() const; + /** @brief Render both faces of the billboard quad (default true). */ + void setDoubleSided(bool enabled); + bool getDoubleSided() const; + /** + * @brief Billboard orientation mode. + * @param mode "screen" (default, full camera basis) or "yaw" (Y-up cylindrical). + */ + void setBillboardMode(const std::string &mode); + std::string getBillboardMode() const; + graphics::Mesh *quadMesh() const { return quad_; } private: @@ -209,6 +231,7 @@ private: void buildQuad(graphics::Graphics *gfx); void updateFrameUv(); void orientToCamera(); + void syncMaterial(); graphics::Graphics *gfx_ = nullptr; graphics::Texture *texture_ = nullptr; @@ -231,6 +254,67 @@ private: float pivotX_ = 0.5f, pivotY_ = 0.5f; float tintR_ = 1.f, tintG_ = 1.f, tintB_ = 1.f, tintA_ = 1.f; bool visible_ = true; + float alphaCutoff_ = 0.5f; + bool depthWrite_ = true; + bool doubleSided_ = true; + bool yawBillboard_ = false; +}; + +/** + * @brief HD-2D presentation look inspired by HD2DURP (DOF + bloom + pixel sampling). + * + * Applies camera post settings for the miniature look and nearest-neighbor + * filtering on sprite/terrain atlases. Depth-of-field needs Sprite3D masked + * cutout / DopFix depth writes so the focus plane can resolve. + */ +class Hd2dLook { +public: + Hd2dLook() = default; + + /** @brief View-space focus plane distance (default 18). */ + void setFocusDistance(float distance); + float getFocusDistance() const { return focusDistance_; } + /** @brief Max Gaussian blur radius in texels; 0 disables DOF (default 5). */ + void setMaxBlur(float blurPx); + float getMaxBlur() const { return maxBlurPx_; } + /** @brief Distance from focus where blur reaches the max (default 14). */ + void setFocusRange(float range); + float getFocusRange() const { return focusRange_; } + /** @brief HDR bloom intensity (default 0.35). */ + void setBloomIntensity(float intensity); + float getBloomIntensity() const { return bloomIntensity_; } + /** @brief HDR bloom threshold (default 1.2). */ + void setBloomThreshold(float threshold); + float getBloomThreshold() const { return bloomThreshold_; } + + /** + * @brief Write DOF + bloom onto a Camera3D (render-thread). + * @param camera Non-null active scene camera. + * @ownership `camera` is borrowed; Hd2dLook does not retain it. + * @lifetime `camera` must remain valid only for this call. + */ + void apply(graphics::Camera3D *camera) const; + + /** + * @brief Point-filter a texture for crisp pixel art (nearest / no mip). + * @param gfx Active Graphics. + * @param texture Atlas or sprite sheet. + * @ownership `gfx` and `texture` are borrowed; Hd2dLook does not retain them. + * @lifetime Both arguments must remain valid only for this call. + */ + void applyPixelSampler(graphics::Graphics *gfx, graphics::Texture *texture) const; + + /** @brief Strong miniature preset matching HD2DURP-style shallow DOF. */ + static Hd2dLook miniature(); + /** @brief Mild look with light bloom and modest DOF. */ + static Hd2dLook soft(); + +private: + float focusDistance_ = 18.f; + float maxBlurPx_ = 5.f; + float focusRange_ = 14.f; + float bloomIntensity_ = 0.35f; + float bloomThreshold_ = 1.2f; }; /** @@ -246,6 +330,18 @@ public: TileMap3D *newTileMap3D(); /** @brief Create a 3D billboard sprite bound to the given Graphics. */ Sprite3D *newSprite(graphics::Graphics *gfx); + /** + * @brief Create an HD-2D look preset with soft defaults. + * @ownership Caller owns the returned object and must delete it. + * @lifetime Valid until the caller deletes it; independent of Hd2D. + */ + Hd2dLook *newLook(); + /** + * @brief Create the miniature (strong DOF) look preset. + * @ownership Caller owns the returned object and must delete it. + * @lifetime Valid until the caller deletes it; independent of Hd2D. + */ + Hd2dLook *newMiniatureLook(); }; } // namespace eve::hd2d diff --git a/src/modules/hd2d/Hd2dLook.cpp b/src/modules/hd2d/Hd2dLook.cpp new file mode 100644 index 00000000..f2fdc14f --- /dev/null +++ b/src/modules/hd2d/Hd2dLook.cpp @@ -0,0 +1,75 @@ +#include "hd2d/Hd2d.h" + +#include "common/Exception.h" +#include "graphics/Graphics.h" +#include "graphics/RenderSystem3D.h" +#include "graphics/Texture.h" + +#include +#include + +namespace eve::hd2d { + +void Hd2dLook::setFocusDistance(float distance) { + if (!std::isfinite(distance) || distance < 0.f) + throw eve::Exception("Hd2dLook.setFocusDistance: distance must be finite and >= 0"); + focusDistance_ = distance; +} + +void Hd2dLook::setMaxBlur(float blurPx) { + if (!std::isfinite(blurPx) || blurPx < 0.f) + throw eve::Exception("Hd2dLook.setMaxBlur: blur must be finite and >= 0"); + maxBlurPx_ = std::min(blurPx, 32.f); +} + +void Hd2dLook::setFocusRange(float range) { + if (!std::isfinite(range) || range <= 0.f) + throw eve::Exception("Hd2dLook.setFocusRange: range must be finite and > 0"); + focusRange_ = range; +} + +void Hd2dLook::setBloomIntensity(float intensity) { + if (!std::isfinite(intensity) || intensity < 0.f) + throw eve::Exception("Hd2dLook.setBloomIntensity: intensity must be finite and >= 0"); + bloomIntensity_ = std::min(intensity, 8.f); +} + +void Hd2dLook::setBloomThreshold(float threshold) { + if (!std::isfinite(threshold) || threshold < 0.f) + throw eve::Exception("Hd2dLook.setBloomThreshold: threshold must be finite and >= 0"); + bloomThreshold_ = std::min(threshold, 16.f); +} + +void Hd2dLook::apply(graphics::Camera3D *camera) const { + if (!camera) throw eve::Exception("Hd2dLook.apply: null camera"); + camera->setDepthOfField(focusDistance_, maxBlurPx_, focusRange_); + camera->setBloom(bloomIntensity_, bloomThreshold_); +} + +void Hd2dLook::applyPixelSampler(graphics::Graphics *gfx, graphics::Texture *texture) const { + if (!gfx) throw eve::Exception("Hd2dLook.applyPixelSampler: null graphics"); + if (!texture) throw eve::Exception("Hd2dLook.applyPixelSampler: null texture"); + gfx->setTextureSampler(texture, "nearest", "none", 1.f, 0.f); +} + +Hd2dLook Hd2dLook::miniature() { + Hd2dLook look; + look.focusDistance_ = 16.f; + look.maxBlurPx_ = 7.f; + look.focusRange_ = 12.f; + look.bloomIntensity_ = 0.45f; + look.bloomThreshold_ = 1.1f; + return look; +} + +Hd2dLook Hd2dLook::soft() { + Hd2dLook look; + look.focusDistance_ = 22.f; + look.maxBlurPx_ = 3.f; + look.focusRange_ = 18.f; + look.bloomIntensity_ = 0.2f; + look.bloomThreshold_ = 1.4f; + return look; +} + +} // namespace eve::hd2d diff --git a/src/modules/hd2d/Sprite3D.cpp b/src/modules/hd2d/Sprite3D.cpp index b334ddd1..9892d168 100644 --- a/src/modules/hd2d/Sprite3D.cpp +++ b/src/modules/hd2d/Sprite3D.cpp @@ -3,6 +3,7 @@ #include "common/Assert.h" #include "common/Exception.h" #include "graphics/Graphics.h" +#include "graphics/Material.h" #include "graphics/Mesh.h" #include "graphics/RenderSystem3D.h" #include "graphics/Texture.h" @@ -13,6 +14,7 @@ #include #include #include +#include #include namespace eve::hd2d { @@ -50,11 +52,14 @@ void Sprite3D::buildQuad(graphics::Graphics *gfx) { renderable_ = graphics::Renderable3D::create(); if (!renderable_) throw eve::Exception("Sprite3D: Renderable3D create failed"); renderable_->setMesh(quad_); - auto* material = gfx->newMaterial(); + auto *material = gfx->newMaterial(); material->setSurfaceMode("masked"); material->setReceiveLight(false); material->setAlbedoTexture(texture_); material->setTint(tintR_, tintG_, tintB_, tintA_); + material->setAlphaCutoff(alphaCutoff_); + material->setDepthWrite(depthWrite_); + material->setDoubleSided(doubleSided_); renderable_->setMaterial(material); // 2D sprites are self-lit (HD-2D characters), not shaded by 3D lights. renderable_->setReceiveLight(false); @@ -65,6 +70,17 @@ void Sprite3D::buildQuad(graphics::Graphics *gfx) { updateFrameUv(); } +void Sprite3D::syncMaterial() { + if (!renderable_ || !renderable_->getMaterial()) return; + auto *material = renderable_->getMaterial(); + material->setSurfaceMode("masked"); + material->setAlphaCutoff(alphaCutoff_); + material->setDepthWrite(depthWrite_); + material->setDoubleSided(doubleSided_); + material->setTint(tintR_, tintG_, tintB_, tintA_); + material->setAlbedoTexture(texture_); +} + void Sprite3D::updateFrameUv() { if (!gfx_ || !quad_) return; std::vector uv(8); @@ -91,7 +107,7 @@ void Sprite3D::setTexture(graphics::Texture *texture) { texture_ = texture; if (renderable_) { renderable_->setTexture(texture); - renderable_->getMaterial()->setAlbedoTexture(texture); + syncMaterial(); } } graphics::Texture *Sprite3D::getTexture() const { return texture_; } @@ -183,16 +199,29 @@ void Sprite3D::orientToCamera() { const float length2 = glm::dot(forward, forward); if (!std::isfinite(length2) || length2 < 1e-12f) return; forward /= std::sqrt(length2); - glm::vec3 right = glm::cross(forward, glm::vec3(cam.upX, cam.upY, cam.upZ)); - const float rightLength2 = glm::dot(right, right); - if (!std::isfinite(rightLength2) || rightLength2 < 1e-12f) return; - right /= std::sqrt(rightLength2); - // Use the camera basis, not eye-to-sprite: all sprites remain parallel to - // the image plane, including off-center sprites and rolled cameras. + glm::mat4 rotation(1.f); - rotation[0] = glm::vec4(-right, 0.f); - rotation[1] = glm::vec4(glm::cross(right, forward), 0.f); - rotation[2] = glm::vec4(forward, 0.f); + if (yawBillboard_) { + // Cylindrical (Y-up) billboard: face the camera on XZ only. + glm::vec3 flat(-forward.x, 0.f, -forward.z); + const float flatLen2 = glm::dot(flat, flat); + if (!std::isfinite(flatLen2) || flatLen2 < 1e-12f) return; + flat /= std::sqrt(flatLen2); + const glm::vec3 right = glm::normalize(glm::cross(glm::vec3(0.f, 1.f, 0.f), flat)); + rotation[0] = glm::vec4(-right, 0.f); + rotation[1] = glm::vec4(0.f, 1.f, 0.f, 0.f); + rotation[2] = glm::vec4(flat, 0.f); + } else { + glm::vec3 right = glm::cross(forward, glm::vec3(cam.upX, cam.upY, cam.upZ)); + const float rightLength2 = glm::dot(right, right); + if (!std::isfinite(rightLength2) || rightLength2 < 1e-12f) return; + right /= std::sqrt(rightLength2); + // Use the camera basis, not eye-to-sprite: all sprites remain parallel to + // the image plane, including off-center sprites and rolled cameras. + rotation[0] = glm::vec4(-right, 0.f); + rotation[1] = glm::vec4(glm::cross(right, forward), 0.f); + rotation[2] = glm::vec4(forward, 0.f); + } float yaw, pitch, roll; glm::extractEulerAngleYXZ(rotation, yaw, pitch, roll); renderable_->setRotation(yaw, pitch, roll); @@ -235,7 +264,7 @@ void Sprite3D::setTint(float r, float g, float b, float a) { tintA_ = std::max(0.f, std::min(1.f, a)); if (renderable_) { renderable_->setTint(tintR_, tintG_, tintB_, tintA_); - renderable_->getMaterial()->setTint(tintR_, tintG_, tintB_, tintA_); + syncMaterial(); } } void Sprite3D::setVisible(bool visible) { @@ -244,4 +273,35 @@ void Sprite3D::setVisible(bool visible) { } bool Sprite3D::getVisible() const { return visible_; } +void Sprite3D::setAlphaCutoff(float cutoff) { + if (!std::isfinite(cutoff)) throw eve::Exception("Sprite3D.setAlphaCutoff: cutoff must be finite"); + alphaCutoff_ = std::max(0.f, std::min(1.f, cutoff)); + syncMaterial(); +} +float Sprite3D::getAlphaCutoff() const { return alphaCutoff_; } + +void Sprite3D::setDepthWrite(bool enabled) { + depthWrite_ = enabled; + syncMaterial(); +} +bool Sprite3D::getDepthWrite() const { return depthWrite_; } + +void Sprite3D::setDoubleSided(bool enabled) { + doubleSided_ = enabled; + syncMaterial(); +} +bool Sprite3D::getDoubleSided() const { return doubleSided_; } + +void Sprite3D::setBillboardMode(const std::string &mode) { + if (mode == "yaw" || mode == "cylindrical" || mode == "y") { + yawBillboard_ = true; + } else if (mode == "screen" || mode == "camera" || mode.empty()) { + yawBillboard_ = false; + } else { + throw eve::Exception("Sprite3D.setBillboardMode: expected 'screen' or 'yaw'"); + } + orientToCamera(); +} +std::string Sprite3D::getBillboardMode() const { return yawBillboard_ ? "yaw" : "screen"; } + } // namespace eve::hd2d diff --git a/test/dap.cpp b/test/dap.cpp index f026031f..876be8b6 100644 --- a/test/dap.cpp +++ b/test/dap.cpp @@ -882,9 +882,11 @@ TEST_CASE("devtools.dap.caughtErrorPausesAtReportSite") { // Never detach a thread that captures this test's stack. Release a // possible late debugger stop and wait for deterministic teardown. dbg.setBreakOnError(false); - dbg.resume(); - for (int i = 0; i < 200 && !scriptDone.load(); ++i) + if (dbg.isPaused()) dbg.resume(); + for (int i = 0; i < 200 && !scriptDone.load(); ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); + dap.poll(); + } } if (scriptThread.joinable()) scriptThread.join(); CHECK(scriptDone.load()); diff --git a/test/hd2d_look_contract.cpp b/test/hd2d_look_contract.cpp new file mode 100644 index 00000000..90c7937b --- /dev/null +++ b/test/hd2d_look_contract.cpp @@ -0,0 +1,47 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include + +#include "graphics/RenderSystem3D.h" +#include "hd2d/Hd2d.h" + +TEST_CASE("hd2d.look.miniatureWritesCameraDofAndBloom") { + eve::hd2d::Hd2dLook look = eve::hd2d::Hd2dLook::miniature(); + CHECK(look.getMaxBlur() > 0.f); + CHECK(look.getBloomIntensity() > 0.f); + + eve::graphics::Camera3D *cam = eve::graphics::Camera3D::createCamera(); + REQUIRE(cam != nullptr); + look.apply(cam); + CHECK_EQ(cam->getDofFocusDistance(), look.getFocusDistance()); + CHECK_EQ(cam->getDofMaxBlur(), look.getMaxBlur()); + CHECK_EQ(cam->getDofFocusRange(), look.getFocusRange()); + CHECK_EQ(cam->getBloomIntensity(), look.getBloomIntensity()); + CHECK_EQ(cam->getBloomThreshold(), look.getBloomThreshold()); +} + +TEST_CASE("hd2d.look.rejectsInvalidFocusRange") { + eve::hd2d::Hd2dLook look; + bool threw = false; + try { + look.setFocusRange(0.f); + } catch (...) { + threw = true; + } + CHECK(threw); +} + +TEST_CASE("hd2d.sprite.dopFixMaterialDefaults") { + eve::hd2d::Sprite3D sprite; + CHECK(sprite.getDepthWrite()); + CHECK(sprite.getDoubleSided()); + CHECK_EQ(sprite.getAlphaCutoff(), 0.5f); + CHECK_EQ(sprite.getBillboardMode(), std::string("screen")); + sprite.setBillboardMode("yaw"); + CHECK_EQ(sprite.getBillboardMode(), std::string("yaw")); + sprite.setAlphaCutoff(0.7f); + CHECK_EQ(sprite.getAlphaCutoff(), 0.7f); + sprite.setDepthWrite(false); + CHECK(!sprite.getDepthWrite()); +}