Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 50 additions & 18 deletions apps/web/tf2/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { APPLICATION_BUILD as __PLAYSRC_APPLICATION_BUILD__, WASM_SHA256 as __PL
import { TF2_PRESENTATION_SCHEMA, Tf2WorkerClient, Tf2WorkerError, mergePublicationSnapshots, type CoverageSample, type LoadedGame, type ResourceConfiguration, type SimulationPublication, type VisibilityResult } from "@playsrc/game-tf2-browser"
import { Tf2EquipmentProfile, Tf2EquipmentPresentation, equippedWeaponSlots, equipmentPipelinePoseRequests, type Tf2EquipmentPreview } from "@playsrc/game-tf2-browser/equipment"
import { selectionTransitionMark, selectionTransitionDraw } from "./selection-transition-profile"
import { teamModelPlayback, TeamModelSkinning, type TeamModelPlayback } from "./team-model-playback"
import { initializeTf2GameUiIntegration, type Tf2GameUiIntegration } from "@playsrc/game-tf2-browser/gameui-integration"
import type { Tf2GameUiRequest, Tf2LoadingPhase } from "@playsrc/game-tf2-browser/gameui"
import {
Expand Down Expand Up @@ -478,7 +479,10 @@ export class Tf2Application {
#teamSelectionRenderRevision = 0
#pendingClassSelectionTeam?: 2 | 3
readonly #teamSelectionPoses = new Map<string, PosedModel>()
readonly #teamSelectionAnimations = new Map<string, Readonly<{ sequence: string; startedSeconds: number; previousSeconds: number }>>()
readonly #teamSelectionAnimations = new Map<string, TeamModelPlayback>()
readonly #teamSelectionSkinning = new TeamModelSkinning()
#teamSelectionUpdateTask: Promise<void> | undefined
#teamSelectionUpdateTime = 0
#teamAdmission?: Readonly<{ generation: number; resolve(): void; reject(error: Error): void }>
#hudRootCounts?: Readonly<{ playerStatus: number; ammo: number }>
#hudContext?: SessionHudContext
Expand Down Expand Up @@ -1969,6 +1973,7 @@ export class Tf2Application {
this.#pendingProjectileTimeline = []
this.#teamSelectionPoses.clear()
this.#teamSelectionAnimations.clear()
this.#teamSelectionSkinning.clear()
this.#pendingClassSelectionTeam = undefined
this.#pendingPresentation = undefined
this.#preparedPresentation = undefined
Expand Down Expand Up @@ -2468,6 +2473,7 @@ export class Tf2Application {
this.#classSelection?.dispatch({ kind: "hide" })
this.#neutral()
if (document.pointerLockElement === this.#canvas) await document.exitPointerLock()
this.#teamSelectionUpdateTime = 0
this.#teamSelection.dispatch({ kind: "show", server })
}

Expand All @@ -2484,46 +2490,46 @@ export class Tf2Application {
const authored = this.#teamSelectionModelPanels
const viewport = this.#viewport()
this.#teamSelectionRenderTask = (async () => {
const requests = authored.flatMap((panel, index) => {
if (panel.sequence === "idle") return []
const requests = authored.flatMap((panel) => {
const artifact = artifacts.models.get(panel.model.toLowerCase())
const timing = artifact?.sequences.find((sequence) => sequence.label.toLowerCase() === panel.sequence.toLowerCase())
if (!artifact || !timing) throw new Error(`TF2 authored team-door sequence is unavailable: ${panel.model}:${panel.sequence}`)
const prior = this.#teamSelectionAnimations.get(panel.name)
const current = prior?.sequence === panel.sequence
? prior
: Object.freeze({ sequence: panel.sequence, startedSeconds: now, previousSeconds: 0 })
const elapsed = Math.max(0, now - current.startedSeconds)
if (elapsed > timing.durationSeconds && this.#teamSelectionPoses.has(panel.name)) return []
const step = teamModelPlayback(this.#teamSelectionAnimations.get(panel.name), panel, now, timing.durationSeconds)
this.#teamSelectionAnimations.set(panel.name, step.state)
if (!step.sample) return []
const request = Object.freeze({
identity: 0x1000 + index,
identity: 0x1000 + panel.panelId,
entityModelPanel: true,
entityAnimationRevision: panel.animationRevision,
modelPanelReset: step.reset,
model: panel.model.toLowerCase(),
activity: panel.sequence,
previousElapsedSeconds: Math.min(current.previousSeconds, elapsed),
elapsedSeconds: elapsed,
previousElapsedSeconds: step.previousElapsed,
elapsedSeconds: step.elapsed,
currentTimeSeconds: now,
frameTimeSeconds: Math.max(0, elapsed - current.previousSeconds),
frameTimeSeconds: step.frameTime,
planarSpeed: 0,
screenAspectRatio: viewport.width / viewport.height,
worldFarPlane: 1000,
skin: panel.skin,
lod: 0,
bodygroups: Object.freeze(artifact.bodygroupCounts.map(() => 0)),
})
this.#teamSelectionAnimations.set(panel.name, Object.freeze({ ...current, previousSeconds: elapsed }))
return [Object.freeze({ panel: panel.name, request })]
return [Object.freeze({ panel: panel.name, state: step.state, request })]
})
if (requests.length > 0) {
const posed = await client.models(generation, encodeModelPoseBatch(requests.map((value) => value.request)))
if (generation !== this.#generation || !this.#teamSelection?.state().visible) return
if (generation !== this.#generation || revision !== this.#teamSelectionRenderRevision || !this.#teamSelection?.state().visible) return
for (const item of posed) {
const selected = requests.find((candidate) => candidate.request.identity === item.identity)
if (!selected) throw new Error("TF2 team-door pose identity differs from its authored request")
this.#teamSelectionPoses.set(selected.panel, item)
this.#teamSelectionSkinning.observe(selected.panel, item)
this.#teamSelectionAnimations.set(selected.panel, { ...selected.state, sampledSeconds: selected.request.elapsedSeconds, transitioning: item.transitioning })
}
}
const panels: readonly ModelPanelPass[] = authored.map((panel, index) => {
const pose = this.#teamSelectionPoses.get(panel.name)
const pose = this.#teamSelectionSkinning.needsPose(panel.name) ? this.#teamSelectionPoses.get(panel.name) : undefined
return Object.freeze({
identity: panel.name,
model: panel.model,
Expand All @@ -2540,13 +2546,36 @@ export class Tf2Application {
})
const result = await renderer.renderModelPanels(panels)
if (generation !== this.#generation || revision !== this.#teamSelectionRenderRevision) return
const doorProfile = (globalThis as any).__playsrcProfile
if (doorProfile?.captureTeamDoors === true) {
const frames = doorProfile.teamDoorFrames ??= []
if (frames.length < 4096) frames.push({ at: performance.now(), now, milliseconds: result.milliseconds,
panels: authored.map(panel => ({ name: panel.name, animation: panel.animation, sequence: panel.sequence,
timing: this.#teamSelectionAnimations.get(panel.name),
cycle: this.#teamSelectionPoses.get(panel.name)?.cycle,
matrices: Array.from(this.#teamSelectionPoses.get(panel.name)?.boneMatrices ?? []) })) })
}
this.#set({ teamSelectionModels: result.panels.map((panel) => `${panel.identity}:${panel.model}:${panel.skin}:${panel.primitives}`).join("|") })
})().catch((error) => {
if (generation !== this.#generation || !this.#teamSelection?.state().visible) return
this.#set({ phase: "Failed", gameUi: "failure", detail: this.#failureDetail(error, "TF2 team model rendering failed") })
}).finally(() => { this.#teamSelectionRenderTask = undefined })
}

#updateTeamSelection(timeSeconds: number): void {
if (!this.#teamSelection?.state().visible || !this.#client || this.#teamSelectionUpdateTask
|| timeSeconds < this.#teamSelectionUpdateTime) return
this.#teamSelectionUpdateTime = timeSeconds + 0.1
const generation = this.#generation, epoch = this.#selectionViewportEpoch
this.#teamSelectionUpdateTask = this.#client.teamSelection(generation).then(server => {
if (generation !== this.#generation || epoch !== this.#selectionViewportEpoch || !this.#teamSelection?.state().visible) return
if (JSON.stringify(server) !== JSON.stringify(this.#teamSelection.state().server)) this.#teamSelection.dispatch({ kind: "update", server })
}).catch(error => {
if (generation === this.#generation && epoch === this.#selectionViewportEpoch && this.#teamSelection?.state().visible)
this.#set({ phase: "Failed", gameUi: "failure", detail: this.#failureDetail(error, "TF2 team state update failed") })
}).finally(() => { this.#teamSelectionUpdateTask = undefined })
}

#hudPresentationObservation(
observation?: ReturnType<Tf2HudIntegration["probe"]>,
currentBinding?: Tf2HudBinding,
Expand Down Expand Up @@ -4629,7 +4658,10 @@ export class Tf2Application {
if (this.#view.localMatchVisible) this.#localMatch?.frame(timeSeconds)
if (this.#equipment?.visible()) { this.#equipment.frame(timeSeconds); this.#renderEquipment() }
if (this.#classSelection?.state().visible) this.#classSelection.frame(timeSeconds)
if (this.#teamSelection?.state().visible) this.#teamSelection.frame(timeSeconds)
if (this.#teamSelection?.state().visible) {
this.#updateTeamSelection(timeSeconds)
this.#teamSelection.frame(timeSeconds)
}
} catch (error) {
this.#paused = true
this.#set({ phase: "Failed", gameUi: "failure", detail: this.#failureDetail(error, "VGUI frame failed") })
Expand Down
55 changes: 55 additions & 0 deletions apps/web/tf2/src/team-model-playback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export type TeamModelPlayback = Readonly<{
modelRevision: number
animationRevision: number
sequence: string
startedSeconds: number
previousSeconds: number
lastPaintSeconds: number
sampledSeconds: number | null
transitioning: boolean
}>

/** Templates already contain sequence zero at cycle zero. Keep those pixels
* and their static GPU pipeline until an actual pose differs; once deformed,
* idle still has to restore the retained skinned instance. */
export class TeamModelSkinning {
readonly #templates = new Map<string, Float32Array>()
readonly #deformed = new Set<string>()

observe(name: string, pose: Readonly<{ sequence: number; cycle: number; boneMatrices: Float32Array; flex: readonly unknown[] }>): void {
if (this.#deformed.has(name)) return
const template = this.#templates.get(name)
if (!template && pose.sequence === 0 && pose.cycle === 0 && pose.flex.length === 0) {
this.#templates.set(name, pose.boneMatrices)
return
}
if (!template || pose.flex.length !== 0 || template.length !== pose.boneMatrices.length
|| template.some((value, index) => value !== pose.boneMatrices[index])) this.#deformed.add(name)
}

needsPose(name: string): boolean { return this.#deformed.has(name) }
clear(): void { this.#templates.clear(); this.#deformed.clear() }
}

/** Presentation scheduling only; the studio owner resolves cycle and pose. */
export function teamModelPlayback(
prior: TeamModelPlayback | undefined,
panel: Readonly<{ modelRevision: number; animationRevision: number; sequence: string }>,
now: number,
duration: number,
): Readonly<{ state: TeamModelPlayback; elapsed: number; previousElapsed: number; frameTime: number; sample: boolean; reset: boolean }> {
const reset = !prior || prior.modelRevision !== panel.modelRevision
const changed = reset || prior?.animationRevision !== panel.animationRevision
const current: TeamModelPlayback = changed
? { ...panel, startedSeconds: reset ? now : prior!.lastPaintSeconds, previousSeconds: 0, lastPaintSeconds: now, sampledSeconds: null, transitioning: false }
: prior!
const elapsed = Math.min(Math.max(0, now - current.startedSeconds), duration)
return {
state: { ...current, previousSeconds: elapsed, lastPaintSeconds: now },
elapsed,
previousElapsed: Math.min(current.previousSeconds, elapsed),
frameTime: Math.max(0, now - (prior?.lastPaintSeconds ?? now)),
sample: current.sampledSeconds === null || current.sampledSeconds < duration || current.transitioning,
reset,
}
}
52 changes: 52 additions & 0 deletions apps/web/tf2/tests/team-model-playback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { expect, test } from "bun:test"
import { teamModelPlayback, TeamModelSkinning } from "../src/team-model-playback"

const panel = { sequence: "hoveropen", modelRevision: 1, animationRevision: 1 }
test("samples exact terminal pose even when a render interval crosses its duration", () => {
const initial = teamModelPlayback(undefined, panel, 1, 30 / 65)
const middle = { ...initial.state, sampledSeconds: 0.1, previousSeconds: 0.1 }
const terminal = teamModelPlayback(middle, panel, 2, 30 / 65)
expect(terminal.sample).toBe(true)
expect(terminal.elapsed).toBe(30 / 65)
expect(teamModelPlayback({ ...terminal.state, sampledSeconds: terminal.elapsed }, panel, 3, 30 / 65).sample).toBe(false)
})
test("idle, same-sequence commands, interrupted changes and recreated panels cannot reuse an old endpoint", () => {
const done = { ...teamModelPlayback(undefined, panel, 1, 1).state, sampledSeconds: 1 }
for (const next of [
{ ...panel, sequence: "idle", animationRevision: 2 },
{ ...panel, animationRevision: 2 },
{ ...panel, modelRevision: 2 },
]) {
const step = teamModelPlayback(done, next, 1.02, 1)
expect(step.sample).toBe(true)
expect(step.elapsed).toBeCloseTo(next.modelRevision === panel.modelRevision ? 0.02 : 0, 8)
expect(step.previousElapsed).toBe(0)
}
expect(teamModelPlayback(undefined, { ...panel, sequence: "idle" }, 0, 0).sample).toBe(true)
})

test("short sequences keep sampling until the studio transition owner has finished fading", () => {
const hover = { ...panel, sequence: "hover" }
const initial = teamModelPlayback(undefined, hover, 1, 5 / 30)
const ended = { ...initial.state, sampledSeconds: 5 / 30, transitioning: true }
expect(teamModelPlayback(ended, hover, 1.18, 5 / 30).sample).toBe(true)
expect(teamModelPlayback({ ...ended, transitioning: false }, hover, 1.21, 5 / 30).sample).toBe(false)
})

test("retains exact template pixels until deformation and restores idle through the same skinned owner", () => {
const state = new TeamModelSkinning()
const idle = { sequence: 0, cycle: 0, boneMatrices: Float32Array.of(1, 0, 0, 2), flex: [] }
state.observe("door", idle)
expect(state.needsPose("door")).toBe(false)
state.observe("door", { ...idle, cycle: 1, boneMatrices: idle.boneMatrices.slice() })
expect(state.needsPose("door")).toBe(false)
state.observe("door", { ...idle, sequence: 1, boneMatrices: Float32Array.of(0.5, 0, 0, 2) })
expect(state.needsPose("door")).toBe(true)
state.observe("door", idle)
expect(state.needsPose("door")).toBe(true)
state.observe("disabled-first", { ...idle, sequence: 1 })
expect(state.needsPose("disabled-first")).toBe(true)
state.clear()
state.observe("door", idle)
expect(state.needsPose("door")).toBe(false)
})
Loading