15 KiB
2D 与 3D 基础图形绘制 API 设计
目标:提供接近 Skia Canvas 的基础图形接口,并将相同的画笔、路径和批处理思想扩展到 3D 场景。
当前实现状态:共享 Paint、2D Path/Canvas、3D SceneCanvas、screen/world 粗线与 虚线、cap/join、coverage AA、2D 填充规则、3D 线框/填充空间图形、LOD/cache、 PrimitiveScene generation handle/批量事务/脚本 proxy,以及 Vulkan/WebGPU 真实 depth/blend/cull pass 已落地。Box2D debug draw 和 editor GizmoSnapshot 已迁移到共享 图元层。Vulkan/WebGPU 的 2D+3D 离屏读回、完整与
runtime-3d裁剪组合均已验收。GPU picking attachment 仍是后续扩展;当前实现只保留objectId数据,不把它描述为可读回的 picking 能力。
关联:3D渲染管线.md、模块编排与裁剪架构.md、
重构代码质量与系统完整性规范.md。
1. 范围
本 API 服务 2D 程序图形、编辑器覆盖层,以及 3D 场景中的线、折线、虚线、箭头、网格、圆弧、 AABB、OBB、球、胶囊、圆柱、圆锥和视锥。物理调试、导航和 Gizmo 应复用它,不再各自创建临时 Mesh。
它不替代 Sprite、Tilemap、模型、粒子等声明式系统,不提供 Skia 全量的 SVG/PDF/排版/复杂滤镜, 也不让基础图形默认参与阴影、GBuffer、PBR 或物理碰撞。长期、受光照的游戏对象仍使用正式 Mesh。
2. 总体结构
flowchart LR
C2[Canvas2D] --> D2[FrameDrawList2D]
C3[SceneCanvas3D] --> D3[FrameDrawList3D]
P[Paint / StrokeStyle] --> C2
P --> C3
D2 --> B2[PrimitiveBatcher2D]
D3 --> B3[PrimitiveBatcher3D]
B2 --> G[IGraphicsPrimitiveBackend]
B3 --> G
G --> VK[Vulkan]
G --> WG[WebGPU]
2D/3D 共享 Color、Paint、线帽、连接、虚线、fill/stroke、抗锯齿、混合和状态栈;但入口分开:
2D 使用 Canvas 坐标和 clip,3D 使用世界坐标、Camera、深度和可选屏幕空间线宽。不要把几十个高层
方法继续塞进 Graphics,也不要用一个充满无意义参数的 Canvas<T>。
2.1 公共值类型
enum class PaintMode { Fill, Stroke, FillAndStroke };
enum class LineCap { Butt, Square, Round };
enum class LineJoin { Miter, Bevel, Round };
enum class WidthSpace { ScreenPixels, WorldUnits };
enum class DashSpace { ScreenPixels, WorldUnits };
enum class DepthMode { TestAndWrite, TestOnly, Ignore };
enum class CullMode { None, Back, Front };
struct DashPattern {
std::vector<float> intervals; // draw, gap, draw, gap...
float phase = 0.f;
DashSpace space = DashSpace::ScreenPixels;
};
struct StrokeStyle {
float width = 1.f;
WidthSpace widthSpace = WidthSpace::ScreenPixels;
LineCap cap = LineCap::Butt;
LineJoin join = LineJoin::Miter;
float miterLimit = 4.f;
std::optional<DashPattern> dash;
};
struct Paint {
Color color = Color::white();
PaintMode mode = PaintMode::Fill;
StrokeStyle stroke{};
BlendMode blend = BlendMode::Alpha;
bool antialias = true;
};
dash 数组必须非空、数量为偶数、每项有限且大于零;宽度、尺寸和半径必须有限且非负。参数违约用
EV_PARAM_CHECK。GPU 容量等可恢复失败由帧提交返回结构化 Result<DrawStatistics>,不使用
lastError。Canvas 记录时复制 owning value,不保留调用方 span 或栈上顶点指针。
3. 2D Canvas
class Canvas2D {
public:
virtual ~Canvas2D() = default;
virtual void save() = 0;
virtual void restore() = 0;
virtual void concat(const glm::mat3&) = 0;
virtual void clipRect(Rect) = 0;
virtual void drawPoint(glm::vec2, const Paint&) = 0;
virtual void drawLine(glm::vec2, glm::vec2, const Paint&) = 0;
virtual void drawPolyline(std::span<const glm::vec2>, bool closed, const Paint&) = 0;
virtual void drawRect(Rect, const Paint&) = 0;
virtual void drawRoundedRect(Rect, CornerRadii, const Paint&) = 0;
virtual void drawCircle(glm::vec2 center, float radius, const Paint&) = 0;
virtual void drawEllipse(Rect bounds, const Paint&) = 0;
virtual void drawArc(Rect bounds, Angle start, Angle sweep, const Paint&) = 0;
virtual void drawPath(const Path2D&, const Paint&) = 0;
};
Path2D 首期支持 move、line、二次/三次 Bézier、close,以及 non-zero/even-odd 填充规则。它是 CPU
owning value,可缓存细分结果;Canvas 记录不可变快照或缓存 handle。默认坐标原点左上、Y 向下、
单位为 Canvas 像素,线宽按屏幕像素。纹理和文字暂由现有 IGraphics2D 提供,后续可迁入同一门面。
4. 3D Scene Canvas
struct SceneDrawContext {
glm::mat4 view, projection;
glm::vec3 cameraPosition;
glm::ivec2 viewportSize;
float nearPlane, farPlane;
};
struct ScenePaint : Paint {
DepthMode depth = DepthMode::TestOnly;
CullMode cull = CullMode::None;
std::uint32_t layer = 0;
std::uint64_t objectId = 0; // 0 = 不参与 picking
};
class SceneCanvas3D {
public:
virtual ~SceneCanvas3D() = default;
virtual void save() = 0;
virtual void restore() = 0;
virtual void concat(const glm::mat4& model) = 0;
virtual void drawPoint(glm::vec3, const ScenePaint&) = 0;
virtual void drawLine(glm::vec3, glm::vec3, const ScenePaint&) = 0;
virtual void drawPolyline(std::span<const glm::vec3>, bool closed, const ScenePaint&) = 0;
virtual void drawRay(glm::vec3 origin, glm::vec3 direction, float length,
const ScenePaint&) = 0;
virtual void drawArrow(glm::vec3 from, glm::vec3 to, const ArrowStyle&,
const ScenePaint&) = 0;
virtual void drawTriangle(glm::vec3, glm::vec3, glm::vec3, const ScenePaint&) = 0;
virtual void drawQuad(glm::vec3 center, glm::quat rotation, glm::vec2 size,
const ScenePaint&) = 0;
virtual void drawDisk(glm::vec3 center, glm::vec3 normal, float radius,
const ScenePaint&) = 0;
virtual void drawArc(glm::vec3 center, glm::vec3 normal, glm::vec3 zeroDirection,
float radius, Angle start, Angle sweep, const ScenePaint&) = 0;
virtual void drawGrid(const Grid3D&, const ScenePaint&) = 0;
virtual void drawAabb(const Aabb&, const ScenePaint&) = 0;
virtual void drawObb(const Obb&, const ScenePaint&) = 0;
virtual void drawSphere(glm::vec3 center, float radius, const ScenePaint&) = 0;
virtual void drawCapsule(glm::vec3 a, glm::vec3 b, float radius, const ScenePaint&) = 0;
virtual void drawCylinder(glm::vec3 a, glm::vec3 b, float radius, const ScenePaint&) = 0;
virtual void drawCone(glm::vec3 apex, glm::vec3 axis, float height, float radius,
const ScenePaint&) = 0;
virtual void drawFrustum(const FrustumCorners&, const ScenePaint&) = 0;
};
首期 Path3D 不在当前接口中。空间曲线必须先明确采用平面路径、相机朝向 ribbon,还是具有旋转最小标架的
空间管线;三种语义的 join、fill 和深度行为不同,不能用一个未定义语义的 drawPath 混在一起。
当前调用方使用 owning 3D polyline 表达空间折线和曲线细分结果,后续若有至少两个真实曲线 consumer,
再单独稳定 SpatialPath3D 契约。
Canvas 由当前 3D pass 根据 SceneDrawContext 创建,不持有 Camera3D* 或 Scene 指针。Context 是
单帧值快照。Canvas 为 render-thread affine,同步记录命令,不调用脚本或未知 callback。
4.1 3D 粗线
不能依赖 Vulkan 原生宽线:设备支持不一致,也无法可靠实现 join、cap 和 dash。统一使用 shader-expanded segment/ribbon:
ScreenPixels在裁剪空间展开,远近保持恒定像素宽,适合 Gizmo 和调试图;WorldUnits在世界空间形成朝向摄像机的 ribbon,透视下自然缩小;- segment 先在 view space 裁剪 near plane,避免投影后爆线;
- 折线携带前后方向,miter 超限退化为 bevel,round cap/join 使用解析覆盖或小扇形;
- 零长度 segment 退化为 point,不产生 NaN。
世界单位粗线仍是 billboard ribbon,不是固定圆截面管道;后者应使用 filled cylinder 或正式 Mesh。
4.2 虚线
虚线沿整条 polyline 累计弧长,不能每个 segment 重启。顶点携带累计距离,fragment shader 以
(distance + phase) mod period 计算覆盖。
- screen dash 的视觉节奏不随相机距离变化;world dash 表达真实世界长度;
- 闭合路径按总长度处理接缝,避免首尾双 gap;
- dash cap 服从
LineCap,边缘使用 coverage AA,不依赖 MSAA; - 动画只更新 phase,几何缓存可复用;
- 首期 screen dash 按投影端点近似,曲线按屏幕误差自适应细分,双后端算法与误差上限一致。
4.3 空间图形
| 图形 | Stroke | Fill |
|---|---|---|
| AABB / OBB / Frustum | 边线 | 面三角形 |
| Sphere | 三个正交大圆 | UV sphere / icosphere |
| Capsule | 轴向边 + 端圆弧 | capsule 网格 |
| Cylinder / Cone | 端圈、轮廓、母线 | 带端盖网格 |
| Disk / Arc / Grid | 边线/网格线 | disk/sector;Grid 不填充 |
细分使用共享 CPU unit-geometry cache,按质量档位复用索引,变换/颜色走 instance。默认 Medium,也可 按屏幕误差选 LOD。API 不返回临时 Mesh,不建立第二套几何资源真源。
5. 即时与长期绘制
Canvas/DrawList 只在当前帧有效。长期图形由 PrimitiveScene 唯一持有 descriptor:
using PrimitiveHandle = RuntimeHandle<PrimitiveTag>;
[[nodiscard]] Result<PrimitiveHandle> add(PrimitiveDescriptor);
[[nodiscard]] Result<UpdateStatus> update(PrimitiveHandle, PrimitiveDescriptor);
[[nodiscard]] Result<RemoveStatus> remove(PrimitiveHandle);
RenderSystem 每帧只读快照并投影到 DrawList。句柄在删除、clear、reload 后用 generation/owner epoch 检测 stale。它不创建 ECS Entity;若图形是游戏实体表现,由对应 ECS component 做权威 owner。
已实现的长期缓存是每个 slot 的 owning CPU 命令缓存,不是 GPU 常驻网格或实例化缓冲。 第一次绘制展开固定细分图形;未修改的后续帧直接复制命令,相机变化不使缓存失效, 裁剪、虚线和屏幕线宽仍使用当前帧 context 求解。成功 update/updateMany 使对应 slot 失效(包括仅修改颜色或变换);失败更新保持缓存。remove/clear 释放缓存,旧帧持有独立副本。 render 包括 const 调用均限 owner 线程,不保留调用方 Canvas,也不调用回调。 目标 Canvas 剩余预算不足时,tryRender 返回结构化失败且不提交该图形,之前提交的图形保留, 拒绝命令数记入 droppedCommands;render 仅为兼容包装,失败时抛异常,不静默截断。 cacheHits 对长期场景按复用的可见 slot 计数。该实现省去几何重复展开,但仍有每帧命令复制和 GPU 上传。 Vulkan 单次提交、WebGPU 单次 flush 均将排序后的各组顶点合并为一次上传,再按组设置绘制状态。 共享 clip 坐标采用引擎 RH/ZO/Y-down 约定;WebGPU 顶点阶段适配 Y 方向,与现有 Mesh 路径一致。
脚本首期只暴露长期 handle API,避免 Squirrel 每帧逐图形调用;即时 Canvas 服务 C++、编辑器和 DevTools。脚本 proxy 不持有 Canvas 裸指针。
6. Pass、排序与 Picking
3D 图形分为读场景 depth 的 TestAndWrite/TestOnly pass 和最后绘制的 Ignore x-ray pass。
默认 TestOnly,避免调试线污染后续深度。命令按 layer、depth、pipeline key 排序;透明 fill 在
同 layer 内后向前排序。顺序敏感调用方使用 layer,不承诺碰巧按调用顺序绘制。
objectId != 0 时可写 primitive picking attachment,与现有 Scene picking 共用读回协议。ID 由调用方
分配,图形系统只透传。后端不支持时必须通过 capability 明确报告,不能静默伪造命中。
7. 后端与批处理
新增窄接口 IGraphicsPrimitiveBackend。高层 tessellation、排序在 backend-neutral graphics 中;
Vulkan/WebGPU 只做 buffer upload、pipeline、bind 和 draw。
每帧:记录 owning 命令;resolve transform/curve/shape;按维度、fill/stroke、depth、blend、AA、 picking 组成 pipeline key;合并到 frame ring buffer;按 batch draw;fence 后复用。
容量从软预算增长到硬上限;超硬上限返回 Result diagnostic,并报告 dropped 数,禁止无日志截断。
统计至少包含 command、segment、triangle、batch、上传字节、缓存命中和 dropped 数。
8. 用法示例
ScenePaint route;
route.color = Color(0.2f, 0.8f, 1.f, 1.f);
route.mode = PaintMode::Stroke;
route.stroke.width = 3.f;
route.stroke.cap = LineCap::Round;
route.stroke.join = LineJoin::Round;
route.stroke.dash = DashPattern{{12.f, 6.f}, 0.f, DashSpace::ScreenPixels};
route.depth = DepthMode::TestOnly;
scene.drawPolyline(routePoints, false, route);
scene.drawArrow(routePoints[routePoints.size() - 2], routePoints.back(), {}, route);
ScenePaint bounds = route;
bounds.color = Color(1.f, 0.7f, 0.1f, 0.8f);
bounds.stroke.dash.reset();
scene.drawObb(selectionBounds, bounds);
9. 实现分期与验收
P0:线与框架
- Paint、DrawList、状态栈;2D/3D point、line、polyline;
- screen/world width,cap/join,screen/world dash,AABB、OBB、Grid、Arrow;
- Vulkan/WebGPU 共享 contract test、capability present/absent 测试;
- 真实 Camera + depth 的 Canvas readback 图像测试。
P1:路径与空间图形
- 2D Path/Bézier、rect、rounded rect、circle、ellipse、arc;
- 3D disk、arc、sphere、capsule、cylinder、cone、frustum;
- fill/stroke、透明排序、LOD/cache;
- 物理 debug draw 和 editor gizmo 各迁移一条生产路径,删除重复实现。
P2:长期对象与工具
- PrimitiveScene generation handle、批量更新、脚本 proxy;
- picking、离屏目标、统计、硬预算 failure injection、裁剪 profile;
- 迁移导航、路径、骨骼/碰撞体等真实消费者。
只有接口和 mock 单测不算完成。每期必须有生产 consumer、双后端契约测试、裁剪构建、失败注入, 以及通过引擎 framebuffer readback 得到的真实画面证据。
10. 架构契约
- 消费者依赖 Canvas/PrimitiveScene 窄接口,不依赖 Vulkan/WebGPU 类;
- PrimitiveScene 或 ECS component 是唯一权威 owner,DrawList 只是单帧投影;
- Canvas 不跨帧、不保留临时 raw pointer,公开 API 标明 ownership/lifetime/thread/reentrancy;
Result、handle 和提交结果均[[nodiscard]],脚本绑定投影结构化错误;- 不新增第二套状态、
lastError、含混 bool 或无预算 TODO/allowlist; - 实现交付前执行 architecture contracts、模块依赖图、严格绑定、focused tests 和
git diff --check。