
1. 项目整体定位
Claude of Duty 是一个运行在浏览器中的第一人称射击游戏原型,技术栈以 Vite、Three.js r180、WebGL2、原生 ES Modules 为主。它不是传统意义上的"网页小游戏",而是一个把现代 FPS 游戏常见模块迁移到浏览器环境中的实验性工程。项目没有外部模型、图片贴图、HDRI 或音频文件,绝大多数可见和可听内容都在运行时由代码程序化生成,包括世界几何、PBR 材质、角色、武器、粒子、贴花、环境光照和音效。
从代码结构看,它并没有使用 React、Vue 等前端 UI 框架,也没有引入物理引擎、后处理框架或音频资源库。项目运行入口是 index.html,其中只提供全屏 canvas#game 和 DOM HUD 容器 div#ui。真正的游戏启动流程集中在 src/main.js:创建配置,实例化 Engine,注册所有子系统,初始化,预热 shader,启动帧循环。
这套工程的核心不是页面路由或组件状态,而是一个浏览器内的游戏引擎小内核。可以把它理解成:
text
index.html
-> src/main.js
-> Engine
-> Registry
-> 11 个 subsystem
-> update / fixedUpdate / lateUpdate / render
2. Engine + Registry 的骨架设计
核心调度代码在 src/core/engine.js 和 src/core/registry.js。Engine 自己并不关心玩家怎么移动、枪怎么开火、AI 怎么寻路、画面怎么后处理,它只提供统一生命周期和共享上下文。每个子系统按固定接口暴露能力:
js
export class SomeSystem {
static id = 'some';
static deps = ['render'];
async init(ctx) {}
fixedUpdate(h, ctx) {}
update(dt, ctx) {}
lateUpdate(dt, ctx) {}
resize(w, h, ctx) {}
dispose() {}
}
Registry 通过 static id 建立系统索引,通过 static deps 做拓扑排序。例如 world 依赖 materials 和 physics,player 依赖 physics、world、render,fx 依赖 render 和 materials。这样 main.js 中的注册顺序可以不严格等于初始化顺序,Registry 会根据依赖关系得到正确顺序。
共享上下文 ctx 是子系统之间通信的核心。它包括:
text
ctx.scene 世界场景
ctx.camera 世界相机
ctx.viewScene 第一人称武器/手臂场景
ctx.viewCamera 第一人称视角模型相机
ctx.canvas WebGL canvas
ctx.config 质量、FOV、灵敏度等配置
ctx.events 同步事件总线
ctx.input 输入聚合器
ctx.time 帧时间、固定步长、插值 alpha
ctx.rng 可 fork 的随机数
ctx.get(id) 获取必需子系统
ctx.peek(id) 获取可选子系统
这里有两个非常重要的设计点。
第一,子系统之间原则上不直接 import 彼此,而是通过 ctx.get('physics')、ctx.peek('player') 查询。这避免了复杂循环依赖,也让每个目录有明确所有权。比如武器系统不会直接 import 玩家系统,它只在运行时拿到 player,如果玩家不存在,viewmodel 也可以独立运行。
第二,ctx.events 是跨系统事件通道。武器发射 weapon:fire,FX、Audio、AI 都会监听;物理发出 bullet:impact,FX 做弹孔和火花,Audio 做撞击声,AI 做压制反应;玩家发出 player:footstep,AI 听见脚步,Audio 播放脚步声。事件是同步分发的,但 payload 大多是预分配对象,避免每帧或每次开火产生过多垃圾对象。
3. 主循环:固定物理和可变渲染分离
Engine.step() 是整个游戏每帧运行的核心。它首先计算 rawDt,并将单帧 delta 限制在 0.1 秒以内,防止切后台或断点调试后模拟瞬间跳跃。之后更新 ctx.time:
text
time.raw 未缩放真实时间
time.dt 当前帧 delta
time.elapsed 缩放后的累计时间
time.fixed 固定步长,默认 1/120 秒
time.alpha 物理插值比例
time.frame 帧号
每帧调用顺序是:
text
1. input.beginFrame()
2. fixedUpdate(FIXED_DT) x N
3. update(dt)
4. lateUpdate(dt)
5. render.render(ctx)
6. input.endFrame()
fixedUpdate 运行在 120Hz 固定步长上,主要服务物理、角色移动、弹道等确定性逻辑。update(dt) 用真实帧 delta,适合动画、AI 状态、摄像机 feel、音频 listener 更新等。lateUpdate(dt) 在所有普通更新之后执行,适合那些必须读取"最终姿态"的系统:例如 UI 要在相机最终姿态后投影世界标记,武器要在 viewmodel 姿态确定后发出 muzzle 事件,AI 要在 agent 更新后同步 hitbox。
这种设计将"游戏模拟"与"渲染帧率"解耦。帧率低时,单个渲染帧内可能执行多个固定步;帧率高时,某些帧可能没有 fixed step。time.alpha 则让物理对象可以在上一固定步和当前固定步之间插值,减少刚体和角色在低帧率下的抖动。
4. 输入系统:DOM 事件到游戏动作
输入聚合在 src/core/input.js。它把键盘、鼠标和 gamepad 统一成每帧快照。原始 DOM 事件只写入 pending set,真正的 pressed、released、down 状态在 beginFrame() 中结算。
按键映射通过 ACTIONS 定义:
text
forward/back/left/right -> WASD 和方向键
jump -> Space
crouch -> Ctrl 或 C
sprint -> Shift
reload -> R
leanLeft/leanRight -> Q/E
swapWeapon -> 数字键和 Tab
pause -> Escape
鼠标移动只有在 pointer lock 后才会写入 look.x/look.y。canvas 左键点击会请求 pointer lock。Input 还支持 frozen,截图模式会冻结输入,避免真实鼠标键盘干扰确定性镜头。
这个系统的一个细节是边缘事件只在当前帧有效:actionPressed('reload') 只在按下那一帧为 true。因此子系统必须在 update() 中读取,而不是在延迟很久之后再读。
5. Player:移动状态机、摄像机 feel、血量和 hitbox
玩家系统入口是 src/player/index.js。文件头部已经明确说明它负责 movement state machine、camera feel、health。真正的碰撞不在这里算,而是全部交给 physics.createCharacter() 生成的胶囊角色控制器。
5.1 初始化流程
PlayerSystem.init(ctx) 做了几件关键事情:
- 获取
physics,创建独立 RNG。 - 创建
Movement、CameraRig、Health。 - 从
world.spawn(0)获取出生点,再用physics.groundHeight()把脚底落到真实地面。 - 初始化 movement 的位置、yaw、pitch。
- 将 camera rig 应用到
ctx.camera。 - 创建玩家 hitbox:一个在
physics.LAYER.PLAYER上的 capsule collider,surface 是flesh。 - 注册低血量后处理 pass 到 render。
- 监听
damage:dealt、explosion、bullet:impact。
这里的 hitbox 设计值得注意。玩家本体的移动胶囊不直接参与自己的子弹或角色碰撞,而是额外创建一个 AI 可射击的 collider。AI 命中玩家时,最终通过事件 damage:dealt 指向玩家,玩家监听后调用 applyDamage()。
5.2 鼠标视角只消费一次
_consumeLook(dt) 防止同一帧内重复消费鼠标 delta。因为在一个渲染帧里可能有多个 fixed step,如果每个 fixed step 都吃一遍鼠标,视角会抖或过快。代码通过 _lookFrame 记录当前帧号,确保每帧只处理一次。
视角变化会写入 movement.yaw 和 movement.pitch,同时限制 pitch 在配置范围内。ADS 会影响灵敏度,mantle 时视角输入被削弱,体现翻越动作中的身体约束。
5.3 fixedUpdate 和 update 分工
fixedUpdate(h, ctx) 做确定性移动:
text
_consumeLook()
movement.latchInput(frame)
movement.adsAmount = adsAmount
movement.step(h)
update(dt, ctx) 做摄像机、血量和状态发布:
text
_consumeLook()
_updateAds()
_drainMovementEvents()
health.update()
rig.update()
rig.applyTo(ctx.camera)
lowHealthPass.sync()
_syncHitbox()
_publishState()
这里的 _drainMovementEvents() 会把 movement 内部的一次性标记转成事件。例如落地发 player:land,脚步发 player:footstep,跳跃发 player:jump,翻越发 player:mantle。摄像机系统也会同步收到落地、脚步、滑铲等反馈,产生 bob、landing dip、recoil、trauma shake 等效果。
5.4 状态发布和 HUD 适配
_publishState() 只在离散状态变化时发 player:state,避免每帧事件泛滥。payload 包含 stance、sprinting、sliding、ads、grounded、mantling、health 等字段。
UI 不直接解析复杂 movement 对象,而是调用 player.getHudState(),得到一个固定 shape 的预分配对象。这样 UI 和 player 解耦,HUD 只关心 health、move、sprint、crouch、ads、position 等表现层数据。
6. Weapons:程序化武器、膛内模型、后坐力、弹道和事件延迟
武器系统入口是 src/weapons/index.js。它负责三类内容:第一人称武器模型和动画、开火/换弹逻辑、弹道模拟。
6.1 初始化和武器状态
WeaponSystem.init(ctx) 创建:
text
WeaponMaterials
ProjectileSim
Viewmodel
rifle / smg / pistol 三把武器
每把武器会根据 WEAPON_DEFS 生成状态:
text
def 武器定义
pattern 确定性 recoil pattern
mag 弹匣内子弹数
chambered 膛内是否有弹
reserve 备用弹药
mode 当前 fire mode
它不是简单的 ammo--。代码显式模拟"膛内一发":战术换弹可以保留膛内弹,从而显示 magSize + 1;空仓换弹则需要从新弹匣中送一发进膛,最终是 magSize。
6.2 开火流程
核心函数是 tryFire()。流程如下:
- 检查是否在 reload、switch、fire cooldown 中。
- 如果没有
chambered,进入 dry fire,拉起 bolt hold,返回 false。 - 消耗膛内弹,并尝试从弹匣补入下一发。
- 根据
_shotIndex取 recoil pattern。 - 从相机 forward 得到基础射击方向。
- 根据当前 spread 在圆盘上采样,形成扩散锥。
- 用 viewmodel 的 muzzleWorld 得到枪口世界坐标。
- 调
ProjectileSim.spawn()生成飞行弹丸。 - 给 viewmodel 和 player camera 分别施加 recoil。
- 增加 spread,设置 fireTimer,记录 pending shot。
- 排队 shell ejection。
这说明项目的射击反馈拆得很细:viewmodel recoil 是"枪的视觉反馈",player camera recoil 是"玩家需要控制的视角爬升"。两者不是同一个量。
6.3 Fire mode 状态机
_runTrigger() 支持 auto、burst、semi 三种模式:
text
auto 按住持续 tryFire()
burst 按下后进入 burstLeft 计数,按 burstRpm 连发
semi 只响应 pressed
这种拆法让武器定义可以分别设置 rpm、burstRpm、burstDelay、burstCount。
6.4 lateUpdate 发事件
武器没有在 tryFire() 里立即发 weapon:fire,而是先记 _pendingShots,到 lateUpdate() 中 viewmodel 姿态最终确定后再发:
text
vm.update(dt, state)
vm.muzzleWorld(payload.origin)
vm.boreDir(payload.dir)
ctx.events.emit('weapon:fire', payload)
这个设计很关键。muzzle flash、枪声和光照必须从最终枪口位置发出,而不是从 update 中间态发出。弹壳也是延迟几毫秒后发 weapon:shell,模拟枪机后坐后壳才离开抛壳窗。
6.5 换弹和 dropped magazine
换弹由 viewmodel clip 事件驱动。_onClipEvent() 处理 start、magout、magdrop、magin、boltrelease、end。magdrop 会调用 _dropMagazine(),把弹匣作为一个世界物体丢出去,并通过 physics.spawnDebris() 变成刚体。这是功能设计上的一个完整闭环:动画事件不仅影响 HUD 弹药,也会生成真实物理反馈。
7. Physics:BVH、角色控制、弹道穿透、刚体和 ragdoll
物理系统入口是 src/physics/index.js。项目没有使用 Cannon、Ammo 或 Rapier,而是自己实现静态 BVH、角色胶囊扫掠、刚体、ragdoll 和弹道穿透。
7.1 静态世界和 BVH
PhysicsSystem 内部持有 StaticWorld。world 系统构建关卡后会通过 physics.addStatic() 或 addStaticGroup() 注册碰撞几何。如果没有显式注册,物理系统也有 fallback auto scan:扫描 scene 中 Mesh 和 InstancedMesh,排除 sky、light、particle、decal、viewmodel 等名字,再建立碰撞世界。
静态世界支持:
text
raycast
raycastAny
sphereCast
capsuleCast
overlapCapsule
groundHeight
raycast() 返回的是复用 hit pool 中的对象,注释提醒"read or copy now, never stash"。这是一种典型性能设计:避免每次射线检测都分配新对象。
7.2 动态 colliders
动态 collider 用于 AI hitbox、玩家 hitbox、盒子 proxy 等。Collider 支持 capsule、sphere、box,并带有 layer、surface、owner、part、damageScale。武器打到 AI 头部时,part === 'head' 可以转成 headshot;玩家 hitbox 则让 AI 子弹能检测玩家。
7.3 弹道和伤害事件
fireBullet(opts) 委托给 Ballistics,随后把 impacts 放入 _impactResult。真正的影响通过 emitImpact() 发出:
text
bullet:impact
damage:dealt
bullet:impact 包含 point、normal、incident、surface、damage、exit、actor、part 等信息。FX 用它生成弹孔、火花、尘土;Audio 用它生成表面撞击声;AI 用它判断压制;如果命中 actor 且不是 exit,Physics 还会发 damage:dealt。
这里也能看出事件语义:damage:dealt 表示"对 target 造成伤害",而不是"玩家打中了某物"。因此 UI 和 Audio 都要过滤 target 为 player 的情况,避免玩家被打时出现 hitmarker。
7.4 fixedUpdate、update、lateUpdate
物理系统分三阶段:
text
fixedUpdate:
构建脏 BVH
step rigid bodies
step ragdolls
update:
用 time.alpha 插值刚体渲染 transform
lateUpdate:
把 ragdoll 结果写回 skeleton
更新 debug draw
这种分工和 Engine 的时间设计严密配合。刚体模拟固定步执行,渲染时插值;ragdoll 也是固定步模拟,但骨骼写回要放在 lateUpdate,确保渲染拿到最终骨骼姿态。
8. AI:程序化角色、导航、感知、掩体和帧预算
AI 系统入口是 src/ai/index.js。它包含角色材质、骨骼、动画、导航网格、掩体点、单兵 agent、squad 协作、战斗事件。
8.1 初始化阶段前置重活
AiSystem.init() 会创建 SoldierMaterials,构建 contact ground shadow,初始化 agent/squad 列表,并调用 _bootNav(ctx) 和 prewarmMaterials()。
文档注释写得很明确:导航、garrison 和角色 shader 原本会落到第一帧游玩阶段,导致 400ms 以上冻结。现在它们尽量在 boot 阶段完成。_buildNav() 会基于 physics BVH 创建 NavGrid,再创建 CoverMap:
text
NavGrid(phys, bounds, cell=0.8, radius=0.36, height=1.78)
grid.build()
cover.build()
这说明导航不是手工 NavMesh 文件,而是由碰撞世界反推 walkability grid 和 cover points。
8.2 AI 对事件的响应
_wireEvents(ctx) 显示 AI 如何接入整个战斗系统:
text
weapon:fire 听到玩家枪声,附近弹道造成 suppress
bullet:impact 附近命中产生 suppress 或 hear
damage:dealt 如果 target 是 Agent,则 applyDamage
explosion 听见爆炸、视线无遮挡则受伤
player:footstep 根据 running 决定听觉距离
这让 AI 感知不依赖硬编码调用,而是通过全局战斗事件自然地接收信息。
8.3 Pathfinding 帧预算
requestPath(from, dest, out) 是一个非常实际的性能设计。A* 搜索不是无限调用,而是每帧只有 pathsPerFrame = 2 的预算。如果预算用完,返回 -1,调用方保留旧路径下帧再试。
这避免了一个 squad 同时 repath 时把单帧 CPU 打爆。对玩家来说,路径晚一帧或两帧更新几乎不可见,但性能尖峰会显著降低。
8.4 可见性 LOD 和阴影剔除
_updateRelevance(ctx) 会判断每个 actor 是否可能影响当前帧像素。它不只检查 actor bounding sphere 是否在相机视锥内,还会沿太阳方向 sweep sphere,判断其阴影是否可能投到可见区域。如果 actor 既不可见、其阴影也不可见,就标记:
text
a.lodIrrelevant = true
a.mesh.userData.owNoShadow = true
这样角色仍然可以模拟、被射击、发出声音,但动画频率和阴影开销可以降低。这是渲染与 AI 协同优化的例子。
9. FX:GPU 粒子、贴花、枪口火光、曳光弹和事件驱动效果
FX 系统入口是 src/fx/index.js。它监听武器、物理、玩家、AI 事件,并把它们转成可见效果。
9.1 程序化 atlas 和 ring buffer
初始化时,FX 根据质量预算创建粒子图集和贴花图集:
text
buildParticleAtlas()
buildDecalAtlas()
ParticleLayer(lit/additive/motes)
DecalSystem
HazeSystem
LightPool
ShellSystem
Ambience
粒子容量来自 config.q.particleBudget。不同质量档会得到不同容量,例如 low 只有 2000,而 ultra 是 24000。FX 会把预算拆成 lit particles、additive particles、dust motes、haze、viewmodel particles 等。
注释说明粒子模拟主要在 vertex shader 中完成,CPU 每帧只需要写 uniform 和上传本帧新增 spawn 数据。每个 layer 都是 ring,容量是硬上限,不会无限增长和分配。
9.2 事件映射
FX 监听:
text
bullet:impact -> onImpact -> spawnImpact
bullet:tracer -> tracer
weapon:fire -> onWeaponFire -> muzzleFlash
weapon:shell -> spawnShell
explosion -> explosion
actor:death -> 血/尘/倒地效果
player:land -> 落地尘土
player:footstep -> 脚步尘土
这意味着武器本身不创建火花和贴花,物理本身也不创建视觉效果,它们只发语义事件。FX 是视觉表现层。
9.3 Viewmodel FX 和光源数量稳定
枪口火光有世界空间和 viewmodel 空间两个路径。第一人称枪口 flash 要画在 viewScene 中,否则合成顺序会让火光被错误地压到武器后面。FX 通过 _attachView() 把 viewAdd 和 viewLit 粒子层加入 ctx.viewScene。
同时,枪口 flash 还需要 light pool。代码特别强调 viewmodel scene 的光源数量不能在第一次开火时变化,否则 Three.js 会因为 light count 变化重新编译整个 viewmodel 材质。解决方案是:viewmodel light pool 一开始就加入 scene,平时强度为 0,需要时 flash。这样 shader permutation 稳定,避免首次开火卡顿。
9.4 弹孔目标选择
debugBurst() 和 _findTarget() 展示了截图/调试用效果布置的复杂度。为了让 impacts shot 真正打在墙上,FX 不是简单在相机前放一张虚拟平面,而是向镜头前方发出 9x7 探针射线,寻找距离合适、正对相机、平面支持度高的真实表面。然后在该表面 tangent/bitangent 平面上走弹孔。这个逻辑虽然是 debug staging,但也说明项目对可验证画面非常敏感。
10. UI:DOM HUD、lateUpdate 确定性动画和状态适配
UI 系统入口是 src/ui/index.js。它不是 WebGL 绘制,而是 DOM + CSS overlay。初始化时创建多个 layer:
text
hurtLayer
worldLayer
centreLayer
chromeLayer
并挂载组件:
text
HealthFx
WorldMarkers
DamageArcs
Crosshair
Hitmarkers
Minimap
Compass
MatchBar
Killfeed
AmmoPanel
Prompt
Banner
PauseMenu
10.1 为什么 UI 在 lateUpdate
UI 注释说明:HUD 完全由 lateUpdate 驱动,不用 CSS keyframe 或 transition。每个动画值都用 dt 积分。这有两个好处:
- 截图 harness 可以确定性冻结 HUD。
- HUD 投影世界点时,读取到的是相机最终 transform。
例如 world markers、grenade markers、damage numbers 都要把 world position 投影到屏幕,如果在 player camera rig 更新前算,会产生一帧延迟或位置漂移。
10.2 UI 如何读取业务状态
UI 不直接深入 WeaponSystem 或 PlayerSystem 私有字段,而是优先调用:
text
weapons.getHudState()
player.getHudState()
ai.getHudActors()
这是一种适配器模式。UI 所需数据固定为 ammo、reserve、reloadProgress、ads、spread、health、move、sprint、position 等;底层实现怎么存,UI 不关心。
10.3 UI 如何响应事件
UI 监听 weapon:fire 更新准星扩散,监听 weapon:reload 更新 reload bar,监听 damage:dealt 生成 hitmarker 和 damage number,监听 damage:taken 生成受伤方向弧和屏幕闪烁,监听 actor:death 更新 killfeed,监听 explosion 触发准星 flinch,监听 player:state 更新 ADS、sprint、crouch 状态。
这里同样体现了事件语义的重要性:damage:dealt 如果 target 是 player,则 UI 不画 hitmarker,因为那代表敌人打中了玩家。
10.4 小地图和目标标记
UI 中的 minimap 不是每帧从头生成。lateUpdate() 中有:
text
if (!minimap.bakeDone && frame > 6 && frame % 20 === 0) minimap.tryBake(ctx)
说明小地图会延迟尝试 bake,以免在场景尚未稳定时构建。AI blips 通过 _collectBlips() 从 AI 暴露的 actor 列表读取,再转成固定数组传给 minimap。
11. Audio:Web Audio 合成、空间化、混响和事件消费
音频系统入口是 src/audio/index.js。项目没有任何音频文件,声音全部通过 Web Audio API 合成。
11.1 用户手势启动
浏览器 Web Audio 需要用户手势。AudioSystem.init() 并不立即创建 AudioContext,而是注册 pointerdown、mousedown、keydown、touchstart、wheel 等事件,第一次手势触发后调用 start()。截图模式没有用户手势,因此静音,保证截图字节稳定。
11.2 音频图
start() 创建:
text
NoiseBank 噪声源库
Mixer bus、reverb、ducking、concussion
SpatialField 空间声源、距离、遮挡、传播延迟
Ambience 远处枪声、爆炸、环境 one-shot
Audio 分为 dry voice 和 spatial voice。玩家自己的枪声、UI tick、心跳等是 head-locked dry voice;远处枪声、弹壳、脚步、爆炸是 spatial voice,有距离衰减、声速延迟、混响 send、遮挡等。
11.3 空间环境探测
update(dt, ctx) 每帧更新 listener 到 camera 的位置和朝向。它还会定期执行 _probeSpace():向周围发 9 条射线,探测平均自由距离、天花板、封闭度,再用 classifySpace() 判定当前更像 open、street、room、tight、tunnel。Mixer 根据这个空间权重调整混响和回声。
这是一个很有意思的"无资源音频环境"方案:没有预烘焙 reverb zone,而是根据物理碰撞世界实时估算空间类型。
11.4 音频事件映射和预算
Audio 监听的事件非常全:
text
weapon:fire
weapon:reload
weapon:shell
bullet:impact
bullet:tracer
explosion
player:footstep
player:land
player:state
damage:dealt
damage:taken
actor:death
ai:bark
它还对高频事件做预算。例如 impact 每帧最多处理几个,shell、whizz、step 也有限制。原因很直接:枪战中 bullet impact 和 shell 可能瞬间很多,如果每个都创建 Web Audio graph,会造成音频线程和主线程压力。
12. Render:自定义 HDR 管线和 viewmodel 分离
渲染系统是项目最复杂的部分,入口在 src/render/index.js。它没有使用 Three.js examples 的 EffectComposer,而是自己实现 render target、fullscreen pass 和整个 HDR 管线。
12.1 初始化阶段
RenderSystem.init(ctx) 首先创建 WebGLRenderer:
js
new THREE.WebGLRenderer({
canvas,
antialias: false,
alpha: false,
depth: true,
stencil: false,
powerPreference: 'high-performance',
failIfMajorPerformanceCaveat: false,
})
它要求 WebGL2。如果 renderer.capabilities.isWebGL2 为 false,直接抛错。随后根据质量档创建:
text
CascadedShadowMaps
MaterialPatcher
GBuffer
GTAO
ContactShadows
SSR
TAA
MotionBlur
DepthOfField
Bloom
AutoExposure
LUT
Composite
ViewComposite
FXAA
低画质下很多效果会被关闭。比如 low 下 TAA、GTAO、SSR、volumetrics、motionBlur 都是 false。
12.2 resize 和内部分辨率
resize(w, h, ctx) 中有一个关键计算:
text
pr = min(devicePixelRatio, 1.5)
dw = w * pr
dh = h * pr
rw = dw * q.renderScale
rh = dh * q.renderScale
这意味着高 DPI 屏幕会提高内部渲染分辨率,但最高只乘 1.5。q.renderScale 再按质量档降低,例如 low 是 0.72,medium 是 0.85,高和 ultra 是 1.0。随后为 HDR、viewmodel、ping-pong、GBuffer、AO、SSR、TAA、MotionBlur、DOF、Bloom 等重建 render target。
这里也是项目"卡"的主要原因之一:这些 target 大多是 half float 或 float,GBuffer 还有多个附件。高分屏下显存和带宽压力很明显。
12.3 材质 patch
MaterialPatcher 会把 CSM 阴影、AO、SSR、bounce fill 等逻辑注入 Three.js 标准材质。渲染器在 compile 前也会调用 _patchLikeFrame(),保证预编译出来的是最终会被 frame loop 使用的 program,而不是未 patch 的废弃 program。
这种方式比完全手写所有材质更经济,但比普通 MeshStandardMaterial 更复杂。它依赖 Three.js 的 onBeforeCompile,也意味着 shader permutation 管理非常敏感。
12.4 每帧 render pass 逐步拆解
render(ctx) 的流程非常长,可以按注释中的编号拆解。
第一步是准备:
text
camera.updateMatrixWorld()
viewCamera.updateMatrixWorld()
_collect(scene)
_ensureProbe(ctx)
_syncSun(camera)
_updateRooms()
_updateBounceFill()
_updateViewRig(viewCamera)
_cullLights(cameraPosition)
_adsT = _readAds()
_collect(scene) 负责遍历场景、收集 draw/hide/noShadow 列表,并 patch 新材质。_syncSun() 找到当前主太阳光,_updateViewRig() 更新武器场景里的三点布光。_cullLights() 做点光源距离剔除。_readAds() 从 weapons/player 读当前 ADS 进度。
第二步是 CSM:
text
csm.update(camera, sunDir, sunSoftness)
csm.setJitter(frame % 8)
隐藏不该投影的对象
csm.render(renderer, scene, drawList)
恢复隐藏对象
CSM 使用 WebGLArrayRenderTarget,将多级级联阴影打包进一张 array texture。对象可以通过 userData.owNoShadow 退出阴影绘制,AI 的 LOD 就依赖这个开关。
第三步是 TAA jitter。只有世界相机会 jitter,viewmodel camera 不 jitter。原因是 viewmodel 后面不再经过 TAA,否则武器会因为没有正确 motion vector 而闪烁或拖影。
第四步是 GBuffer prepass。src/render/prepass.js 中 GBuffer 输出三类信息:
text
normal/coverage/material id
velocity
linear depth
velocity 使用当前和上一帧 view-projection 矩阵,且不包含 TAA jitter。skinned 或 morphed 几何写特殊 coverage,让 TAA 知道动态变形像素不能完全信任历史。
第五到第七步是屏幕空间效果:
text
GTAO 基于 GBuffer 的环境遮蔽
ContactShadows 短距离屏幕空间接触阴影
SSR 基于上一帧颜色和深度的屏幕空间反射
这些结果通过 patcher uniforms 注入后续世界材质。
第八步是正向世界渲染:
text
renderer.setRenderTarget(hdrRt)
renderer.clear()
renderer.render(scene, camera)
世界最终进入 HDR half-float target。
第九步是 viewmodel 独立渲染。它不会写入世界 GBuffer,也不会提前混入世界 HDR。RenderSystem 判断 viewScene.children.length > _viewRigChildren,只有 viewScene 中除了灯光 rig 之外还有武器/粒子时才绘制。
viewmodel 渲染前会做几件特殊处理:
text
如果 viewCamera 与 world camera 不一致,则关闭 CSM 强度
关闭 contact shadow,因为 contact buffer 是世界空间
缩放 sky/ground fill,模拟身体遮挡天空光
跳过 interior indirect gate
patch viewScene 材质
渲染到透明 viewRt
这解释了为什么第一人称武器看起来是"世界中的物体",但又不会穿墙、不会被世界 fog/DOF 错误影响。
第十步是 TAA。只处理世界颜色,不处理 viewmodel。第十一步是 motion blur。第十二步是 ADS depth of field,只在 ADS 进度大于 0.01 且有 prepass 时运行。由于 viewmodel 后合成,枪身和 reticle 保持锐利,远处世界可以虚化。
第十三步是注册的自定义 pass。FX 的 haze、Player 的 lowHealthPass 等会通过 render.registerPass() 插入这里。
第十四步是 viewmodel composite。它在 haze/volumetrics 之后、metering/bloom 之前执行。顺序很有讲究:如果合成太早,武器会被世界 fog 当成远处物体处理;如果合成太晚,枪口火光不会参与曝光和 bloom。
第十五步是自动曝光。AutoExposure 对当前颜色做 log luminance reduction,得到 1x1 exposure texture。sky 可以提供 exposure bias,避免低太阳角时暗部测光把天空拉爆。
第十六步是 Bloom,采用 Karis pyramid。第十七/十八步是最终 composite:AgX tone mapping、LUT、vignette、chromatic aberration、grain、sharpen,然后如果没有 TAA 就走 FXAA,最终输出到 canvas。
最后做 bookkeeping:记录世界对象矩阵给下一帧 velocity,更新 _prevVP,恢复 render target。
13. Materials 和 World:程序化资源生成与批处理
材质系统在 src/materials/index.js,世界系统在 src/world/index.js。世界系统初始化时创建 Assembler,按顺序注册 props、build ground、build buildings、build gate、build perimeter、dress street、dress buildings、scatter debris、add lights,最后 A.finalize(this.root, physics)。
Assembler 的职责包括:
text
合并静态几何
批处理重复实例
生成实例化 props
把静态碰撞注册给 physics
收集 stats
world 中还有点光源数量稳定机制。Three.js 会把可见 point light 数量编进 program key,玩家走动导致 visible light count 变化就会触发 shader 重新编译。world._addBallast() 创建一批黑色、零强度、极小 range 的 ballast point light,lateUpdate() 中 _stabiliseLightCount() 预测真实可见点光数量,再用 ballast 补齐到固定目标。这样材质看到的 numPointLights 稳定,避免走过灯光边界时全场重编 shader。
14. Shader 预热和确定性截图
src/core/prewarm.js 是项目性能优化的核心之一。Three.js 的 shader 编译通常是 lazy 的:某种材质、灯光、阴影、skinning、fog 组合第一次被真正绘制时才编译。FPS 中这会造成第一次开火、第一次看到敌人、第一次进入某个房间时卡顿。
预热做几件事:
- 选择多个代表性相机 pose。
- 绑定 1x1 scratch render target,避免编译出 canvas 色彩空间下的错误 program。
- 调
renderer.compileAsync(scene, camera)和renderer.compileAsync(viewScene, viewCamera)。 - 调用 subsystem 的
prewarmMaterials()hook。 - 保存并恢复相机、时间、RNG、accumulator,确保预热不改变后续画面。
它特别强调"预热必须像没发生一样"。如果预热过程生成了粒子、贴花、actor 或推进了时间,截图 diff 会发生变化。因此 fx 默认自暖,render 某些真实 shadow/prepass 绘制路径也被谨慎关闭。
截图系统在 src/dev/shots.js 中提供 window.__SHOTS__、window.__APPLY_SHOT__、window.__PUMP__、window.__READY__。lockstep=1 时 engine 不自己 rAF,而是由 harness 精确 pump N 帧。tools/baseline.mjs 每个 shot 新开页面并固定帧预算,从而保证 TAA、曝光、粒子时间都可复现。
15. 性能优化策略汇总
这个项目的性能优化不是单点,而是一组围绕 WebGL/Three.js 痛点的工程策略。
15.1 质量档控制
src/core/config.js 定义 low、medium、high、ultra。它们控制 renderScale、shadowMapSize、cascades、shadowDistance、TAA、GTAO、SSR、volumetrics、motionBlur、particleBudget、decalBudget 等。默认是 ultra,所以普通机器很容易卡。?q=low&prewarm=0 是最快看到画面的方式。
15.2 降低 shader 编译尖峰
策略包括:
text
boot prewarm
render/world/ai/fx prewarmMaterials
绑定正确 render target 编译
保持 point light 数量稳定
viewmodel light pool 提前挂载
避免 first shot 触发 viewScene light count 变化
15.3 减少 draw call 和对象数量
World 通过 Assembler 合并静态几何,重复物体走 instancing。FX 通过少数 instanced draw call 承载大量粒子、贴花、弹壳。Viewmodel 武器构建时也会合并组件,避免每个螺丝都是独立 draw call。
15.4 避免每帧分配
代码中大量使用预分配对象:
text
事件 payload 复用
Hit pool / impact pool
Vector3 scratch
HUD state object 复用
Particle ring buffer
Audio per-frame budget
AI path buffer
这对浏览器尤其重要,因为频繁分配会带来 GC 抖动,而 FPS 对帧时间尖峰非常敏感。
15.5 分阶段初始化
AI 导航、角色材质、shader 编译、世界几何构建都尽量放在 boot 阶段。代价是启动慢、黑屏时间长;收益是游玩时少卡顿。当前项目缺少加载 UI,所以用户容易误以为"显示不出来"。从产品角度看,后续可以添加 boot progress overlay,至少显示 world/materials/ai/prewarm 阶段。
15.6 帧预算和 LOD
AI A* 每帧最多两个;AI 不可见且阴影不可见时降低动画/阴影开销;Audio 每帧限制 impact、shell、whizz、footstep 触发数量;FX 所有粒子和贴花都有硬容量。这些都是控制最坏情况的设计。
16. 功能链路示例:玩家开一枪发生了什么
把上面的系统串起来,可以看一次完整开火链路:
Input在beginFrame()中记录Mouse0。WeaponSystem.update()读取input.fire。_runTrigger()根据 fire mode 调tryFire()。tryFire()消耗膛内弹、计算 recoil、spread、muzzle、dir,并向ProjectileSim生成弹丸。WeaponSystem.fixedUpdate()推进 projectile simulation。- 弹丸命中后,Physics/Ballistics 发
bullet:impact,如命中 actor 还发damage:dealt。 WeaponSystem.lateUpdate()在 viewmodel 姿态最终确定后发weapon:fire和延迟的weapon:shell。FxSystem收到weapon:fire做 muzzle flash、光、烟、heat haze;收到bullet:impact做火花、弹孔、尘土;收到weapon:shell做弹壳。AudioSystem收到weapon:fire合成枪声,收到bullet:impact合成撞击声,收到weapon:shell合成弹壳落地声。AiSystem收到玩家weapon:fire,敌人 hear,附近弹道造成 suppression。UiSystem收到weapon:fire让准星扩散,收到damage:dealt画 hitmarker 和 damage number。RenderSystem.render()在本帧或随后几帧绘制世界、粒子、viewmodel、后处理和 HUD。
这个链路说明项目的功能设计不是"武器对象直接操纵所有东西",而是典型的事件驱动游戏架构:武器只说"我开火了",物理只说"子弹打到了这里",表现层自己决定该显示什么、播放什么。
17. 当前架构的优点和风险
优点:
text
模块边界清楚,子系统职责明确
Engine 足够薄,业务逻辑不堆在主循环里
事件总线让战斗反馈自然扩展
render 管线完整,接近现代 FPS 的画面结构
程序化资源让项目高度自包含
大量性能设计针对 WebGL 实际痛点
截图和 baseline 工具支持视觉回归
风险:
text
默认 ultra 太重,普通机器首次体验差
boot 阶段缺少可见加载反馈,容易误判黑屏
render 对 WebGL2 float/half-float/MRT/array texture 依赖重,兼容性较窄
MaterialPatcher 和 Three.js 内部 shader key 强耦合,升级 Three 版本风险高
事件 payload 复用要求调用方不要长期保存引用,容易被误用
工具脚本中部分默认端口不统一,例如 profile/playtest 曾使用 8080
18. 总结
Claude of Duty 的核心价值在于它把浏览器前端、Three.js 渲染、游戏引擎循环、程序化内容、FPS 战斗系统、Web Audio 合成、物理与 AI 全部压进一个本地可运行项目里。它的功能设计按照现代 FPS 体验拆分:玩家移动、武器操作、AI 战斗、特效反馈、HUD 信息和空间音频都通过明确子系统实现。它的渲染逻辑不是简单的 Three.js 场景直出,而是一整套 HDR 管线,包括 CSM、GBuffer、GTAO、SSR、TAA、motion blur、DOF、viewmodel 独立合成、曝光、bloom 和 tone mapping。
从工程角度看,最值得学习的是两个方向:一是系统协作方式,尤其是 ctx、Registry 和事件总线如何把复杂功能拆开;二是 WebGL 性能优化方式,尤其是 shader 预热、point light 数量稳定、实例化、对象池、固定步长和确定性截图。它的缺点也同样清楚:默认配置更像评审画质而不是普适运行配置,启动阶段缺少加载 UI,硬件兼容边界偏窄。
如果后续要让它更适合普通用户运行,优先级应该是:默认改为 medium 或 low,增加 boot loading overlay,把 prewarm progress 暴露出来,启动时检测 WebGL extension 并给出降级提示,统一工具端口,最后再考虑在 low 档关闭更多 float target 或减少 GBuffer 成本。这样既保留这套架构的实验价值,也能让第一次打开页面的人不至于面对一块沉默的黑屏。