From 127f55fcf5b7e2e77b21d41bb893e4ffb139a797 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 27 Sep 2026 17:17:47 -0700 Subject: [PATCH] oskiewar v185: restore the 3D playground and bound rendering work --- apple/oskiewar-mac/project.yml | 5 + apple/oskiewar/Sources/MetalSceneView.swift | 136 ++++-- xbox/live/oskiewar.js | 506 ++++++++++++++++---- xbox/live/tests/pool-megatexture.test.mjs | 37 ++ xbox/live/tests/pool-playground.test.mjs | 174 +++++++ xbox/macos-native/PoolDecals.cpp | 83 ++++ xbox/macos-native/PoolDecals.h | 18 + xbox/macos-native/PoolDecals.swift | 72 +++ xbox/macos-native/README.md | 31 ++ xbox/macos-native/main.swift | 37 +- xbox/macos-native/tests/pool-decals.cpp | 50 ++ xbox/tools/build-macos-app.sh | 6 +- 12 files changed, 1033 insertions(+), 122 deletions(-) create mode 100644 xbox/live/tests/pool-megatexture.test.mjs create mode 100644 xbox/live/tests/pool-playground.test.mjs create mode 100644 xbox/macos-native/PoolDecals.cpp create mode 100644 xbox/macos-native/PoolDecals.h create mode 100644 xbox/macos-native/PoolDecals.swift create mode 100644 xbox/macos-native/tests/pool-decals.cpp diff --git a/apple/oskiewar-mac/project.yml b/apple/oskiewar-mac/project.yml index 7a4536adcd..eb356a0bc7 100644 --- a/apple/oskiewar-mac/project.yml +++ b/apple/oskiewar-mac/project.yml @@ -37,6 +37,8 @@ settings: DEVELOPMENT_TEAM: FB5948YR3S CODE_SIGN_STYLE: Automatic SWIFT_VERSION: "5.0" + CLANG_CXX_LANGUAGE_STANDARD: "c++17" + SWIFT_OBJC_BRIDGING_HEADER: "$(SRCROOT)/../../xbox/macos-native/PoolDecals.h" targets: OskiewarMac: @@ -49,6 +51,9 @@ targets: # main.swift because it IS top-level code, which Swift only allows in a # file with that name. - path: ../../xbox/macos-native/main.swift + - path: ../../xbox/macos-native/PoolDecals.swift + - path: ../../xbox/macos-native/PoolDecals.cpp + - path: ../../xbox/macos-native/PoolDecals.h # The Metal scene and its glyph atlas are shared verbatim with the iOS # app — one renderer compiled into both. Listed file by file rather than diff --git a/apple/oskiewar/Sources/MetalSceneView.swift b/apple/oskiewar/Sources/MetalSceneView.swift index 261be54209..71af1f52fd 100644 --- a/apple/oskiewar/Sources/MetalSceneView.swift +++ b/apple/oskiewar/Sources/MetalSceneView.swift @@ -5,6 +5,11 @@ struct SceneVertex { var color: SIMD4 } +struct PoolDecalVertex { + var position: SIMD4 + var uvq: SIMD4 +} + struct ThemeVertex { var position: SIMD4 var uv: SIMD2 @@ -21,7 +26,6 @@ struct ThemeVertex { /// only where their frameworks genuinely differ, and that difference is fenced /// to the few lines below rather than paid for with a second renderer. final class MetalSceneView: MTKView, MTKViewDelegate { - static let maxTriangles = 8_192 /// The HUD writes nameplates, a clock, and a command stream — hundreds of /// glyphs, not thousands. Six vertices each. static let maxGlyphs = 4_096 @@ -34,10 +38,31 @@ final class MetalSceneView: MTKView, MTKViewDelegate { private let commandQueue: MTLCommandQueue private let pipeline: MTLRenderPipelineState private let depthState: MTLDepthStencilState - private let vertexBuffer: MTLBuffer + private let poolDecalPipeline: MTLRenderPipelineState + private let poolDecalDepth: MTLDepthStencilState + private var poolDecalVertices: [PoolDecalVertex] = [] + var poolDecalTexture: ((Int) -> MTLTexture?)? + // Each slot stays owned by its command buffer until the GPU completes it. + // Pool marks can outgrow the initial allocation; never truncate a scene + // before its later-submitted fighters have reached the GPU. + private final class Uploads { + var scene: MTLBuffer? + var glyphs: MTLBuffer? + var theme: MTLBuffer? + var decals: MTLBuffer? + } + private let uploads = (0..<3).map { _ in Uploads() } + private let inFlight = DispatchSemaphore(value: 3) + private var uploadIndex = 0 + + private func reserve(_ buffer: inout MTLBuffer?, bytes: Int) -> Bool { + if let buffer, buffer.length >= bytes { return true } + buffer = device?.makeBuffer(length: max(bytes, max(256, (buffer?.length ?? 0) * 2)), + options: .storageModeShared) + return buffer != nil + } private let glyphAtlas: GlyphAtlas private let glyphPipeline: MTLRenderPipelineState - private let glyphBuffer: MTLBuffer private var glyphVertices: [GlyphVertex] = [] private let sceneLock = NSLock() // The photographic theme: the same retained-atlas contract the Xbox and @@ -49,7 +74,6 @@ final class MetalSceneView: MTKView, MTKViewDelegate { private var themeSolidPipeline: MTLRenderPipelineState? private var themeSoftPipeline: MTLRenderPipelineState? private var themeSoftDepth: MTLDepthStencilState? - private var themeBuffer: MTLBuffer? static let maxThemeQuads = 1_024 private var background = MTLClearColor(red: 7 / 255, green: 8 / 255, blue: 28 / 255, alpha: 1) @@ -60,12 +84,6 @@ final class MetalSceneView: MTKView, MTKViewDelegate { fatalError("Metal is unavailable") } commandQueue = queue - guard let buffer = device.makeBuffer(length: Self.maxTriangles * 3 * - MemoryLayout.stride, options: .storageModeShared) else { - fatalError("Metal scene buffer allocation failed") - } - vertexBuffer = buffer - let source = """ #include using namespace metal; @@ -81,6 +99,17 @@ final class MetalSceneView: MTKView, MTKViewDelegate { fragment float4 scene_fragment(Raster in [[stage_in]]) { return float4(in.color, 1.0); } + struct DecalVertex { float4 position; float4 uvq; }; + struct DecalRaster { float4 position [[position]]; float3 uvq; }; + vertex DecalRaster decal_vertex(const device DecalVertex *v [[buffer(0)]], uint id [[vertex_id]]) { + DecalRaster out; out.position = v[id].position; out.uvq = v[id].uvq.xyz; return out; + } + fragment float4 decal_fragment(DecalRaster in [[stage_in]], texture2d atlas [[texture(0)]]) { + constexpr sampler linear(filter::linear, address::clamp_to_edge); + float4 color = atlas.sample(linear, in.uvq.xy / in.uvq.z); + if (color.a < 0.02) discard_fragment(); + return color; + } """ do { let library = try device.makeLibrary(source: source, options: nil) @@ -90,6 +119,15 @@ final class MetalSceneView: MTKView, MTKViewDelegate { descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm descriptor.depthAttachmentPixelFormat = .depth32Float pipeline = try device.makeRenderPipelineState(descriptor: descriptor) + descriptor.vertexFunction = library.makeFunction(name: "decal_vertex") + descriptor.fragmentFunction = library.makeFunction(name: "decal_fragment") + let blend = descriptor.colorAttachments[0]! + blend.isBlendingEnabled = true + blend.sourceRGBBlendFactor = .sourceAlpha + blend.destinationRGBBlendFactor = .oneMinusSourceAlpha + blend.sourceAlphaBlendFactor = .one + blend.destinationAlphaBlendFactor = .oneMinusSourceAlpha + poolDecalPipeline = try device.makeRenderPipelineState(descriptor: descriptor) } catch { fatalError("Metal scene pipeline failed: \(error)") } @@ -99,13 +137,6 @@ final class MetalSceneView: MTKView, MTKViewDelegate { } catch { fatalError("Metal glyph pipeline failed: \(error)") } - guard let glyphs = device.makeBuffer( - length: Self.maxGlyphs * 6 * MemoryLayout.stride, - options: .storageModeShared) else { - fatalError("Metal glyph buffer allocation failed") - } - glyphBuffer = glyphs - let depth = MTLDepthStencilDescriptor() depth.isDepthWriteEnabled = true depth.depthCompareFunction = .lessEqual @@ -113,6 +144,11 @@ final class MetalSceneView: MTKView, MTKViewDelegate { fatalError("Metal depth state failed") } depthState = state + depth.isDepthWriteEnabled = false + guard let decalState = device.makeDepthStencilState(descriptor: depth) else { + fatalError("Metal decal depth state failed") + } + poolDecalDepth = decalState super.init(frame: frame, device: device) colorPixelFormat = .bgra8Unorm depthStencilPixelFormat = .depth32Float @@ -132,6 +168,7 @@ final class MetalSceneView: MTKView, MTKViewDelegate { func beginFrame(red: Double, green: Double, blue: Double) { sceneLock.lock() sceneVertices.removeAll(keepingCapacity: true) + poolDecalVertices.removeAll(keepingCapacity: true) glyphVertices.removeAll(keepingCapacity: true) for key in themeSolid.keys { themeSolid[key]?.removeAll(keepingCapacity: true) } for key in themeSoft.keys { themeSoft[key]?.removeAll(keepingCapacity: true) } @@ -161,7 +198,6 @@ final class MetalSceneView: MTKView, MTKViewDelegate { values.prefix(12).allSatisfy(\.isFinite) else { return false } sceneLock.lock() defer { sceneLock.unlock() } - guard sceneVertices.count < Self.maxTriangles * 3 else { return false } let ink = SIMD4(Float(clamped255(values[9])), Float(clamped255(values[10])), Float(clamped255(values[11])), 1) @@ -179,10 +215,8 @@ final class MetalSceneView: MTKView, MTKViewDelegate { /// hand a whole frame over as one Float32Array — twelve floats per /// triangle, nine of position and three of ink, the same dozen the /// per-triangle call takes — so the crossing from JavaScript into Swift - /// happens once instead of eight thousand times. Everything above the - /// buffer's capacity is dropped rather than wrapped: a truncated frame is a - /// missing shadow, where a wrapped one is a triangle drawn across the face - /// of the fighter it belonged behind. + /// happens once instead of eight thousand times. The upload buffer grows + /// to fit a complete frame; later-submitted fighters are never truncated. @discardableResult func triangleBatch(_ values: UnsafePointer, count: Int) -> Int { let halfWidth = Float(logicalSize.width / 2) @@ -190,9 +224,7 @@ final class MetalSceneView: MTKView, MTKViewDelegate { guard count > 0, halfWidth > 0, halfHeight > 0 else { return 0 } sceneLock.lock() defer { sceneLock.unlock() } - let room = (Self.maxTriangles * 3 - sceneVertices.count) / 3 - let drawn = min(count, max(0, room)) - guard drawn > 0 else { return 0 } + let drawn = count sceneVertices.reserveCapacity(sceneVertices.count + drawn * 3) var appended = 0 for triangle in 0.., vertexCount: Int) { + sceneLock.lock() + defer { sceneLock.unlock() } + let hw = Float(logicalSize.width / 2), hh = Float(logicalSize.height / 2) + guard hw > 0, hh > 0, vertexCount > 0, vertexCount % 3 == 0 else { return } + poolDecalVertices.removeAll(keepingCapacity: true) + poolDecalVertices.reserveCapacity(vertexCount) + for i in 0...stride), + reserve(&slot.glyphs, bytes: glyphs.count * MemoryLayout.stride), + reserve(&slot.theme, bytes: themeCount * MemoryLayout.stride), + reserve(&slot.decals, bytes: poolDecals.count * MemoryLayout.stride), + let vertexBuffer = slot.scene, let glyphBuffer = slot.glyphs, + let themeBuffer = slot.theme, let decalBuffer = slot.decals else { return } + let decalTexture = poolDecals.isEmpty ? nil : poolDecalTexture?(uploadIndex) pass.colorAttachments[0].clearColor = clear pass.colorAttachments[0].loadAction = .clear pass.depthAttachment.clearDepth = 1 @@ -260,7 +322,15 @@ final class MetalSceneView: MTKView, MTKViewDelegate { encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: vertices.count) } - drawTheme(encoder, solid: themeSolidSnapshot, soft: themeSoftSnapshot) + if let decalTexture, !poolDecals.isEmpty { + _ = poolDecals.withUnsafeBytes { memcpy(decalBuffer.contents(), $0.baseAddress!, $0.count) } + encoder.setRenderPipelineState(poolDecalPipeline) + encoder.setDepthStencilState(poolDecalDepth) + encoder.setVertexBuffer(decalBuffer, offset: 0, index: 0) + encoder.setFragmentTexture(decalTexture, index: 0) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: poolDecals.count) + } + drawTheme(encoder, buffer: themeBuffer, solid: themeSolidSnapshot, soft: themeSoftSnapshot) // Text last and depth-free: the HUD is not in the world, it is over it. if !glyphs.isEmpty { let capacity = glyphBuffer.length / MemoryLayout.stride @@ -275,6 +345,7 @@ final class MetalSceneView: MTKView, MTKViewDelegate { } var frame = SIMD2(Float(stage.width), Float(stage.height)) encoder.setRenderPipelineState(glyphPipeline) + encoder.setDepthStencilState(nil) encoder.setVertexBuffer(glyphBuffer, offset: 0, index: 0) encoder.setVertexBytes(&frame, length: MemoryLayout>.stride, index: 1) @@ -284,6 +355,10 @@ final class MetalSceneView: MTKView, MTKViewDelegate { } encoder.endEncoding() command.present(drawable) + let available = inFlight + command.addCompletedHandler { _ in available.signal() } + uploadIndex = (uploadIndex + 1) % uploads.count + submitted = true command.commit() } @@ -358,14 +433,11 @@ final class MetalSceneView: MTKView, MTKViewDelegate { noWrite.depthCompareFunction = .lessEqual let solidState = try device.makeRenderPipelineState(descriptor: solid) let softState = try device.makeRenderPipelineState(descriptor: soft) - guard let buffer = device.makeBuffer(length: Self.maxThemeQuads * 6 * - MemoryLayout.stride, options: .storageModeShared), - let depth = device.makeDepthStencilState(descriptor: noWrite) else { return } + guard let depth = device.makeDepthStencilState(descriptor: noWrite) else { return } sceneLock.lock() themeSolidPipeline = solidState themeSoftPipeline = softState themeSoftDepth = depth - themeBuffer = buffer themeTextures = loaded sceneLock.unlock() } catch { @@ -437,9 +509,9 @@ final class MetalSceneView: MTKView, MTKViewDelegate { /// Solid cut-outs first so they take part in depth like any face, then /// the soft flashes and explosions over them without writing depth. - private func drawTheme(_ encoder: MTLRenderCommandEncoder, + private func drawTheme(_ encoder: MTLRenderCommandEncoder, buffer: MTLBuffer, solid: [Int: [ThemeVertex]], soft: [Int: [ThemeVertex]]) { - guard let buffer = themeBuffer, let solidPipeline = themeSolidPipeline, + guard let solidPipeline = themeSolidPipeline, let softPipeline = themeSoftPipeline, let softDepth = themeSoftDepth else { return } var offset = 0 let stride = MemoryLayout.stride diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index f6cd742110..e981ad1651 100644 --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -102,7 +102,7 @@ if (hostAnalytics) const hostAc = typeof ac === "function" ? ac : null; // Monotonic count of committed revisions to this piece (next revision included). -const buildVersion = 184; +const buildVersion = 185; const floorY = 1800; // Oskiewar now opens as a versus game. An ordinary web visit hosts a room — // the URL becomes the invitation — and until a friend opens it, all you can @@ -863,7 +863,7 @@ function rasterDecalPatches(decal) { const a = (u-.5)*decal.size*2*(decal.stretch||1), b = (v-.5)*decal.size*2; x = decal.x + (a*c-b*s)*decal.tx; z = decal.z + a*s+b*c; } - z = clamp(z, worldNear+1, worldFar-1); + z = clamp(z, worldNear-(poolOnly()?parkLotMargin:0)+1, worldFar+(poolOnly()?parkLotMargin:0)-1); // Sample every corner against the curved ramp; a tangent-plane sticker // would float over the curve or cut into it at its edges. return {x,y:terrainFloorAt(x,z)-.9,z}; @@ -894,7 +894,16 @@ function drawRasterDecal(decal) { function drawDecals(surface) { if(poolOnly()&&poolDecalsNative&&poolDecalCount){ poolSurfaceMesh ||= captureQuadMesh(drawPoolGeometry); - decalMesh(meshHandle(poolSurfaceMesh),mainNativeCamera(),gridLeft,worldNear,gridWidth,roomDepth); + let handle; + if(nativeRetainedMeshes)handle=meshHandle(poolSurfaceMesh); + else { + if(poolSurfaceMesh.decalHandle===undefined){ + const buffers=nativeMeshBuffers(poolSurfaceMesh); + poolSurfaceMesh.decalHandle=decalMeshUpload(buffers.vertices,buffers.faces); + } + handle=poolSurfaceMesh.decalHandle; + } + if(handle>=0)decalMesh(handle,mainNativeCamera(),gridLeft-parkLotMargin,worldNear-parkLotMargin,gridWidth+parkLotMargin*2,roomDepth+parkLotMargin*2); } if (!decals.length && !bloodDrops.length) return; const now = runtime().monotonicUs; @@ -1590,6 +1599,16 @@ const parkRamps=[ {x:2800,z:-1450,hx:500,hz:380,tx:220,tz:130,height:230}, {x:4700,z:700,hx:650,hz:400,tx:270,tz:180,height:300}, {x:7250,z:0,hx:800,hz:440,tx:400,tz:200,height:340}]; +const parkHalfPipe3D={x:2840,z:0,flat:160,radius:280,deck:70,bank:150,hx:660,hz:750,run:550}; +function parkHalfPipeHeight(x,z){ + const p=parkHalfPipe3D,d=Math.abs(x-p.x),end=Math.abs(z-p.z); + if(d>=p.hx||end>=p.hz)return 0; + const curve=clamp(d-p.flat,0,p.radius); + let height=p.radius-Math.sqrt(Math.max(0,p.radius*p.radius-curve*curve)); + if(d>p.flat+p.radius+p.deck)height=p.radius*(p.hx-d)/p.bank; + const taper=clamp((p.hz-end)/(p.hz-p.run),0,1); + return height*taper*taper*(3-2*taper); +} const parkDeckY=floorY-pool.depth; const parkPalette={pool:[172,212,211],ground:[220,185,151], hills:[[167,188,207],[185,172,209],[171,200,170]],ramp:[218,155,138], @@ -1615,7 +1634,7 @@ function parkGroundAt(x,z){ let height=0; for(const h of parkHills){const r=(x-h.x)**2/h.rx**2+(z-h.z)**2/h.rz**2;if(r<1)height=Math.max(height,h.height*(1-r)**2);} for(const r of parkRamps){const t=Math.min(1,(r.hx-Math.abs(x-r.x))/(r.hx-r.tx),(r.hz-Math.abs(z-r.z))/(r.hz-r.tz));height=Math.max(height,r.height*Math.max(0,t));} - return parkDeckY-height; + return parkDeckY-Math.max(height,parkHalfPipeHeight(x,z)); } function poolFloorAt(x,z=0){ const edge=poolDistance(x,z); @@ -1630,11 +1649,67 @@ function poolSlopeAt(x,z){ const e=.5; return {x:(poolFloorAt(x+e,z)-poolFloorAt(x-e,z))/(2*e),z:(poolFloorAt(x,z+e)-poolFloorAt(x,z-e))/(2*e)}; } -function boundParkBody(p){ - const x=clamp(p.x,gridLeft+25,gridLeft+gridWidth-25),z=clamp(p.z||0,worldNear+25,worldFar-25); +const parkLotMargin=1800; +const brokenParkWindows=new Set(),parkWindowShards=[]; +function insidePark(x,z){return x>=gridLeft&&x<=gridLeft+gridWidth&&z>=worldNear&&z<=worldFar;} +function parkWindowWalls(){ + const l=gridLeft,r=gridLeft+gridWidth,n=worldNear,f=worldFar; + return [[l,n,r,n],[r,n,r,f],[r,f,l,f],[l,f,l,n]].map(([ax,az,bx,bz],id)=>{ + const length=Math.hypot(bx-ax,bz-az),ux=(bx-ax)/length,uz=(bz-az)/length; + return {id,ax,az,bx,bz,length,ux,uz,nx:-uz,nz:ux,width:length/Math.ceil(length/1100)}; + }); +} +function breakParkWindow(wall,bay,at,velocity={}){ + const key=wall.id+':'+bay;if(brokenParkWindows.has(key))return false; + brokenParkWindows.add(key); + releaseQuadMesh(parkWindowMeshes.get(key));parkWindowMeshes.delete(key); + for(let i=0;i<18;i++){ + const phase=i*2.39996,u=(bay+.5)*wall.width+Math.sin(phase)*wall.width*.3; + parkWindowShards.push({x:wall.ax+wall.ux*u,y:at.y-80+Math.cos(phase)*100,z:wall.az+wall.uz*u, + vx:(velocity.vx||0)*.25+Math.sin(phase)*180,vy:-100-Math.abs(Math.cos(phase))*240, + vz:(velocity.vz||0)*.25+Math.cos(phase)*180,life:2.4,size:8+i%4*4,rotation:phase}); + } + if(parkWindowShards.length>216)parkWindowShards.splice(0,parkWindowShards.length-216); + playDrum('glass',.8,panAt(at.x,at.z));emitSignal('park-window-break',-1,wall.id,bay);return true; +} +function resetParkWindows(){ + brokenParkWindows.clear();parkWindowShards.length=0; + for(const mesh of parkWindowMeshes.values())releaseQuadMesh(mesh);parkWindowMeshes.clear(); + releaseQuadMesh(parkBuildingMesh);parkBuildingMesh=null; +} +function parkWallContact(from,to,radius=25){ + const contacts=[]; + for(const w of parkWindowWalls()){ + const old=(from.x-w.ax)*w.nx+(from.z-w.az)*w.nz,dist=(to.x-w.ax)*w.nx+(to.z-w.az)*w.nz; + if(old>=0?dist>=radius:dist<=-radius)continue; + const fraction=clamp(old/(old-dist||1),0,1); + const x=lerp(from.x,to.x,fraction),z=lerp(from.z,to.z,fraction); + const u=(x-w.ax)*w.ux+(z-w.az)*w.uz; + if(u<0||u>w.length)continue; + const bay=Math.min(Math.ceil(w.length/1100)-1,Math.floor(u/w.width)); + contacts.push({w,bay,u,old,dist,fraction}); + } + return contacts; +} +function boundParkBody(p,previous={x:p.x,z:p.z||0}){ + const radius=p.pad===undefined?18:25,height=p.pad===undefined?40:180; + for(const {w,bay,u,old,dist} of parkWallContact(previous,{x:p.x,z:p.z||0},radius)){ + const horizontal=u>bay*w.width+109+radius&&u<(bay+1)*w.width-109-radius; + const wt=parkDeckY-parkBuildingHeight+269,wb=parkDeckY-479; + const impactY=p.y-height*.5,impact=horizontal&&impactY>wt&&impactY450||p.attackKind||p.spin?.rate>5))breakParkWindow(w,bay,{x:p.x,y:impactY,z:p.z},p); + if(horizontal&&brokenParkWindows.has(w.id+':'+bay)&&p.y<=wb&&p.y-height>=wt)continue; + const correction=(old>=0?radius:-radius)-dist; + p.x+=w.nx*correction;p.z=(p.z||0)+w.nz*correction; + const normal=(p.vx||0)*w.nx+(p.vz||0)*w.nz; + p.vx=(p.vx||0)-w.nx*normal;p.vz=(p.vz||0)-w.nz*normal; + } + const x=clamp(p.x,gridLeft-parkLotMargin+25,gridLeft+gridWidth+parkLotMargin-25), + z=clamp(p.z||0,worldNear-parkLotMargin+25,worldFar+parkLotMargin-25); if(x!==p.x)p.vx=0;if(z!==p.z)p.vz=0;p.x=x;p.z=z; const ceiling=parkDeckY-parkBuildingHeight+(p.pad===undefined?70:230); - if(p.y (stageRight - stageLeft) / cameraDoll.width; +let figureDrawScale=null; +const cameraScale = () => figureDrawScale ?? (stageRight - stageLeft) / cameraDoll.width; let playerCameraYaw = 0; let skateIsoSide = 1; let skateIsoYaw = .3; @@ -3117,7 +3193,7 @@ let poolDecalsNative=null,poolDecalCount=0; function clearPoolDecals(){ poolDecalCount=0; poolDecalsNative=typeof decalClear==='function'&&typeof decalStamp==='function'&& - typeof decalMesh==='function'&&nativeRetainedMeshes?decalClear():false; + typeof decalMesh==='function'&&(nativeRetainedMeshes||typeof decalMeshUpload==='function')?decalClear():false; } let decalSeed = 7; function decalRandom() { @@ -3138,7 +3214,7 @@ function addDecal(decal) { if(poolDecalsNative){ for(const patch of rasterDecalPatches(decal)) decalStamp(...patch.uv,...patch.points.flatMap(p=>[ - (p.x-gridLeft)/gridWidth*2048,(p.z-worldNear)/roomDepth*2048])); + (p.x-gridLeft+parkLotMargin)/(gridWidth+parkLotMargin*2)*2048,(p.z-worldNear+parkLotMargin)/(roomDepth+parkLotMargin*2)*2048])); poolDecalCount++; return decal; } @@ -6377,6 +6453,7 @@ function beginFreeskate(now) { resetMonowheel(); resetParkSupply(now); resetParkKids(); + resetParkWindows(); roundStartedAt = now - roundIntroDurationUs(); emitSignal("freeskate", 0, 1, 0); } @@ -6934,6 +7011,7 @@ function consumeSystemButtons(now) { // In freeskate Start opens the pause menu (speed, resume, title). if (freeskateActive()) { freeskateMenu = freeskateMenu ? null : { row: 0, previous: padSnapshots[0]?.down?.slice() || [] }; + if(freeskateMenu&&motorRunning){if(typeof oscillatorStop==='function')oscillatorStop();else if(typeof oscillator==='function')oscillator(motorFrequency,0);motorRunning=false;} playDrum("hat", .5, 0); emitSignal("freeskate-menu", -1, freeskateMenu ? 1 : 0, 0); return false; @@ -9033,6 +9111,7 @@ function resetRound(now, resetMatch = false, keepMap = false) { player.skidFrom = null; player.spin = null; player.directionChanges = []; + player.poolLastSteer = 0; player.lastHorizontal = 0; player.ropeIndex = -1; player.ropeGrabLocked = false; @@ -9462,17 +9541,26 @@ function deathOrbitShot(target, width, progress) { let poolCameraYaw = 0, poolCameraDip = 0, poolCameraReturnYaw = null; // Keep the line from the lens to the rider above the coping and curved walls. function clearPoolCamera(position, subject) { - // Keep the camera inside the building; shallow dips no longer need to - // pull the boom into each bowl. Terrain still clears the entire sightline. - position={...position,x:clamp(position.x,gridLeft+80,gridLeft+gridWidth-80), - z:clamp(position.z,worldNear+80,worldFar-80)}; + // Follow the rider into the lot without putting a solid wall between + // the lens and the body. Terrain still clears the entire sightline. + const outside=!insidePark(subject.x,subject.z); + position={...position,x:clamp(position.x,gridLeft-parkLotMargin+80,gridLeft+gridWidth+parkLotMargin-80), + z:clamp(position.z,worldNear-parkLotMargin+80,worldFar+parkLotMargin-80)}; + if(!outside){position.x=clamp(position.x,gridLeft+80,gridLeft+gridWidth-80); + position.z=clamp(position.z,worldNear+80,worldFar-80);} + else{ + if(subject.xgridLeft+gridWidth)position.x=Math.max(position.x,gridLeft+gridWidth+80); + if(subject.zworldFar)position.z=Math.max(position.z,worldFar+80); + } let y=position.y; for(let i=1;i<20;i++){ const t=i/20,x=lerp(position.x,subject.x,t),z=lerp(position.z,subject.z,t); const surface=poolFloorAt(x,z); y=Math.min(y,(surface-subject.y*t)/(1-t)-65); } - return {...position,y:Math.max(parkDeckY-parkBuildingHeight+80,y)}; + return {...position,y:outside?y:Math.max(parkDeckY-parkBuildingHeight+80,y)}; } function updateCameraDoll(dt, now) { if (poolOnly() && freeskateActive()) { @@ -9502,19 +9590,19 @@ function updateCameraDoll(dt, now) { const zoom=playerCameraZoom,angle=poolCameraYaw+.14*(1-poolCameraDip)+playerCameraYaw; const airHeight=Math.max(0,poolFloorAt(p.x,p.z||0)-p.y); const distance=(1350+Math.min(850,airHeight*.35)-poolCameraDip*120)*zoom; - const look=140+Math.min(180,speed*.07)+(poolCameraReturnYaw!==null?lerp(140,420,clamp(airHeight/600,0,1)):0); + const look=340+Math.min(450,speed*.16)+(poolCameraReturnYaw!==null?lerp(140,420,clamp(airHeight/600,0,1)):0); const lookX=p.x+Math.cos(goal)*look,lookZ=(p.z||0)+Math.sin(goal)*look; const groundFocus=Math.max(poolCameraDip*.28,clamp(airHeight/650,0,1)*.32); - const target={x:lerp(p.x,lookX,.15+poolCameraDip*.4), - y:lerp(p.y-85+clamp(p.vy*.1,-160,220),poolFloorAt(lookX,lookZ)-40,groundFocus), - z:lerp(p.z||0,lookZ,.15+poolCameraDip*.4)}; + const target={x:lerp(p.x,lookX,.65+poolCameraDip*.2), + y:lerp(p.y-130+clamp(p.vy*.1,-160,220),poolFloorAt(lookX,lookZ)-40,groundFocus), + z:lerp(p.z||0,lookZ,.65+poolCameraDip*.2)}; // Lead the tracking spring during fast bank airs so it does not leave // the rider above the frame while still looking at the takeoff point. target.x+=p.vx*.1;target.z+=(p.vz||0)*.1;target.y+=clamp(p.vy*.1,-220,220); target.y=clamp(target.y,p.y-240,p.y+50); const subject={x:p.x,y:p.y-2,z:p.z||0}; const position=clearPoolCamera({x:target.x-Math.cos(angle)*distance, - y:p.y-85-(lerp(260,880,clamp(airHeight/500,0,1))+poolCameraDip*230+Math.min(500,airHeight*.2))*zoom-playerCameraPitch*600, + y:p.y-85-(lerp(145,480,clamp(airHeight/500,0,1))+poolCameraDip*110+Math.min(240,airHeight*.12))*zoom-playerCameraPitch*600, z:target.z-Math.sin(angle)*distance},subject); cameraCenter=target.x;cameraCenterY=target.y;cameraWidth=1650*zoom; cameraDoll.track({target,position,width:cameraWidth,perspective:1,fov:58+poolCameraDip*5,roll:0},dt,8); @@ -10029,6 +10117,7 @@ function fireGun(player, input) { x: pose.muzzle.x, y: pose.muzzle.y, z: pose.muzzle.z, vx: pose.dx * (laser ? 9600 : smg ? 5000 : 4200), vy: pose.dy * (laser ? 9600 : smg ? 5000 : 4200), + vz:(pose.dz||0)*(laser?9600:smg?5000:4200), owner: player.pad, life: 1, rubber: smg, laser, safeUntil: now + 100000, }); @@ -10350,7 +10439,45 @@ function recordBulletTrail(bullet) { bullet.trailCount++; } +function updatePoolBullets(dt,now,combat){ + const t=(now-startedAt)/1e6; + for(const b of bullets){ + if(b.life<=0)continue; + b.previousX=b.x;b.previousY=b.y;b.previousZ=b.z;b.life-=dt; + const steps=Math.max(1,Math.ceil(Math.hypot(b.vx,b.vy,b.vz||0)*dt/18)); + for(let step=0;step0;step++){ + const from={x:b.x,y:b.y,z:b.z}; + b.x+=b.vx*dt/steps;b.y+=b.vy*dt/steps;b.z+=(b.vz||0)*dt/steps; + for(const {w,bay,u} of parkWallContact(from,b,0)){ + const inGlass=u>bay*w.width+109&&u<(bay+1)*w.width-109&& + b.y>parkDeckY-parkBuildingHeight+269&&b.y=poolFloorAt(b.x,b.z)-4||(insidePark(b.x,b.z)&&b.ygridLeft+gridWidth+parkLotMargin|| + b.zworldFar+parkLotMargin){b.life=0;break;} + if(!combat)continue; + for(const target of [...activePlayers(),...parkKids]){ + if(!target.alive||target.dummy||(target.pad===b.owner&&now260)continue; + const hit=sweptProjectileContact([{x1:from.x,y1:from.y,z1:from.z,x2:b.x,y2:b.y,z2:b.z,width:8,role:'bullet',part:'bullet'}],target,t); + if(!hit||hit.separation>3)continue; + b.life=0; + if(target.blocking)breakShield(target,now); + else if(target.civilian){ + if(hit.headshot)popCivilianHead(target,players[b.owner]||players[0],runnerWorldGeometry(target,t),now); + else target.startledUntil=now+900000; + }else if(hit.headshot)killPlayer(target,b.owner,now,'SHOT'); + else applyBodyHit(target,hit.segmentIndex,b.x-b.vx,b.owner,now,1180,125); + break; + } + } + } + for(let i=bullets.length-1;i>=0;i--)if(bullets[i].life<=0)bullets.splice(i,1); +} function updateBullets(dt, now, combat = true) { + if(poolOnly()){updatePoolBullets(dt,now,combat);return;} for (const bullet of bullets) { if (bullet.life <= 0) continue; bullet.vx += windAcceleration * .12 * dt; @@ -10771,6 +10898,12 @@ function itemHandTarget(player, now) { } function gunPose(player, now, input = null) { + if(poolOnly()){ + const pose=runnerWorldGeometry(player,(now-startedAt)/1e6),arm=itemForearm(player,pose); + const hand=arm?{x:arm.x2,y:arm.y2,z:arm.z2}:{x:player.x,y:player.y-110,z:player.z||0}; + const yaw=(player.poolYaw||0)+(player.spin?.angle||0),dx=Math.cos(yaw),dz=Math.sin(yaw); + return {hand,dx,dy:0,dz,muzzle:{x:hand.x+dx*54,y:hand.y,z:hand.z+dz*54}}; + } let aimX = input?.horizontal || player.facing; let aimY = input ? -input.vertical : 0; if (!input && (player.itemAimLocked || player.gunAimLive || @@ -12215,6 +12348,12 @@ function updatePoolPlayer(p, pad, dt, now) { const axis=value=>Math.abs(value)>.18?Math.sign(value)*(Math.abs(value)-.18)/.82:0; const turn=Number(held.includes('ArrowRight'))-Number(held.includes('ArrowLeft')) || axis(pad.leftX||0); const throttle=Number(held.includes('ArrowUp'))-Number(held.includes('ArrowDown')) || axis(pad.leftY||0); + const steer=Math.sign(turn); + if(steer&&steer!==p.poolLastSteer){ + noteDirectionChange(p,now); + p.poolLastSteer=steer; + } + updateSpin(p,dt); const wasGrounded=p.grounded; const oldHeading=p.poolYaw||0; const speedBefore=Math.hypot(p.vx,p.vz||0); @@ -12238,7 +12377,14 @@ function updatePoolPlayer(p, pad, dt, now) { if(!blocking&&p.blocking)playBubbleSound(p,true); p.blocking=blocking; p.shieldCrouched=p.ducking; if(now>=p.attackUntil)p.attackKind=''; - if(!blocking){if(pressed('B'))startMelee(p,'PUNCH',now);if(pressed('Y'))startMelee(p,'KICK',now);} + if(p.itemAction&&now>=p.itemActionUntil)p.itemAction=''; + if(!blocking){ + if(pressed('B'))startMelee(p,'PUNCH',now); + if(pressed('Y')){ + if(p.gunAmmo>0&&now>=(p.nextGunShotAt||0)&&availableArm(p))fireGun(p,{horizontal:1,vertical:0}); + else if(!p.gunAmmo)startMelee(p,'KICK',now); + } + } const mounted=p.skateboard; const dash=pressed('LeftShoulder')&&now>=(p.poolDashReadyAt||0); if(dash){ @@ -12255,6 +12401,7 @@ function updatePoolPlayer(p, pad, dt, now) { // Substep contacts in both horizontal axes, keeping speed tangent to the bowl. const steps=Math.max(1,Math.ceil(dt*120)), step=dt/steps; for(let n=0;ns.part===part && /forearm$/.test(s.role||'')); if(!upper || !lower)continue; Object.assign(upper,{x1:torso.x1+side*12,y1:torso.y1+8,z1:(player.z||0)+side*14, - x2:torso.x1+side*54,y2:torso.y1+16,z2:(player.z||0)+side*24}); + x2:torso.x1+side*36,y2:torso.y1+16,z2:(player.z||0)+side*24}); Object.assign(lower,{x1:upper.x2,y1:upper.y2,z1:upper.z2, - x2:torso.x1+side*92,y2:torso.y1+8,z2:(player.z||0)+side*36}); + x2:torso.x1+side*66,y2:torso.y1+8,z2:(player.z||0)+side*30}); } } if(player.civilian && player.bodyScale){ const k=player.bodyScale; - pose.head.x=player.x+(pose.head.x-player.x)*k;pose.head.y=player.y+(pose.head.y-player.y)*k;pose.head.radius*=k; - for(const b of pose.segments){b.x1=player.x+(b.x1-player.x)*k;b.x2=player.x+(b.x2-player.x)*k;b.y1=player.y+(b.y1-player.y)*k;b.y2=player.y+(b.y2-player.y)*k;b.width*=k;} - } - // A spin: the whole body turns round the vertical axis by the spin's angle. - if (player.spin) { - const angle = player.spin.angle; - const c = Math.cos(angle), s = Math.sin(angle); - const spin = (point, xKey, zKey) => { - const dx = point[xKey] - player.x, dz = (point[zKey] ?? player.z) - player.z; - point[xKey] = player.x + dx * c - dz * s; - point[zKey] = player.z + dx * s + dz * c; - }; - spin(pose.head, "x", "z"); - for (const segment of pose.segments) { spin(segment, "x1", "z1"); spin(segment, "x2", "z2"); } + pose.head.x=player.x+(pose.head.x-player.x)*k;pose.head.y=player.y+(pose.head.y-player.y)*k;pose.head.radius*=k;pose.head.z=(player.z||0)+(pose.head.z-(player.z||0))*k; + for(const b of pose.segments){b.x1=player.x+(b.x1-player.x)*k;b.x2=player.x+(b.x2-player.x)*k;b.y1=player.y+(b.y1-player.y)*k;b.y2=player.y+(b.y2-player.y)*k;b.z1=(player.z||0)+(b.z1-(player.z||0))*k;b.z2=(player.z||0)+(b.z2-(player.z||0))*k;b.width*=k;} } // Mid-turn the body swings round its vertical axis: the old facing's side // rotates away into depth and the new one comes round. @@ -15013,7 +15149,7 @@ function runnerWorldGeometry(player, t) { } if(poolOnly()&&!isHeadOnly(player)){ const z=player.z||0,bank=player.poolLean||0; - if(!player.skateboard&&!pose.spatial){ + if(!player.skateboard&&!pose.spatial&&!player.spin){ for(const bone of pose.segments){ const side=bone.part?.startsWith('left-')?-1:bone.part?.startsWith('right-')?1:0; const width=bone.part?.includes('arm')?22:13; @@ -15030,6 +15166,19 @@ function runnerWorldGeometry(player, t) { lean(pose.head,'y','z'); for(const bone of pose.segments){lean(bone,'y1','z1');lean(bone,'y2','z2');} } + if(poolOnly()&&!player.spiderDummy)constrainSpatialLimbs(pose,player.civilian?(player.bodyScale||1):1); + // A spin: the whole body turns round the vertical axis by the spin's angle. + if (player.spin) { + const angle = player.spin.angle; + const c = Math.cos(angle), s = Math.sin(angle); + const spin = (point, xKey, zKey) => { + const dx = point[xKey] - player.x, dz = (point[zKey] ?? player.z) - player.z; + point[xKey] = player.x + dx * c - dz * s; + point[zKey] = player.z + dx * s + dz * c; + }; + spin(pose.head, "x", "z"); + for (const segment of pose.segments) { spin(segment, "x1", "z1"); spin(segment, "x2", "z2"); } + } if (poolOnly() && player.poolYaw) { const c=Math.cos(player.poolYaw),s=Math.sin(player.poolYaw),z=player.z||0; const rotate=(point,xKey,zKey)=>{const dx=point[xKey]-player.x,dz=(point[zKey]??z)-z; @@ -15219,6 +15368,33 @@ function hangGeometry(player) { // A spatial gait: feet travel backward through stance, lift on recovery, // and arms counter the legs. The distance phase works at every heading. +function constrainSpatialLimbs(pose,scale=1){ + for(const upper of pose.segments){ + const leg=/thigh$/.test(upper.role||''),arm=/upper-arm$/.test(upper.role||''); + if(!leg&&!arm)continue; + const lower=pose.segments.find(b=>b.part===upper.part&&(leg?/shin$/:/forearm$/).test(b.role||'')); + if(!lower)continue; + const root={x:upper.x1,y:upper.y1,z:upper.z1},end={x:lower.x2,y:lower.y2,z:lower.z2}; + const l1=(leg?48:33)*scale,l2=(leg?47:32)*scale; + let axis={x:end.x-root.x,y:end.y-root.y,z:end.z-root.z}; + const distance=Math.hypot(axis.x,axis.y,axis.z); + if(distance<1e-6)axis={x:0,y:1,z:0};else for(const k of ['x','y','z'])axis[k]/=distance; + const reach=clamp(distance,Math.abs(l1-l2)+.01,l1+l2-.01); + let pole={x:upper.x2-root.x,y:upper.y2-root.y,z:upper.z2-root.z}; + const dot=pole.x*axis.x+pole.y*axis.y+pole.z*axis.z; + for(const k of ['x','y','z'])pole[k]-=dot*axis[k]; + let norm=Math.hypot(pole.x,pole.y,pole.z); + if(norm<1e-6){const basis=Math.abs(axis.x)<.9?{x:1,y:0,z:0}:{x:0,y:0,z:1}; + const d=basis.x*axis.x+basis.z*axis.z; + for(const k of ['x','y','z'])pole[k]=basis[k]-axis[k]*d; + norm=Math.hypot(pole.x,pole.y,pole.z);} + const along=(l1*l1-l2*l2+reach*reach)/(2*reach),bend=Math.sqrt(Math.max(0,l1*l1-along*along)); + for(const k of ['x','y','z']){ + upper[k+'2']=lower[k+'1']=root[k]+axis[k]*along+pole[k]/norm*bend; + lower[k+'2']=root[k]+axis[k]*reach; + } + } +} function buildPoolWalkGeometry(p) { const phase=p.poolStridePhase||0,speed=clamp(Math.hypot(p.vx,p.vz||0)/700,0,1); const moving=clamp(Math.hypot(p.vx,p.vz||0)/200,0,1),crouch=clamp(p.crouchBlend||0,0,1); @@ -15254,6 +15430,9 @@ function buildPoolWalkGeometry(p) { bone(pelvis,knee,name+'-thigh',name+'-leg',11);bone(knee,foot,name+'-shin',name+'-leg',10); const upper=shoulders[index],swing=-footX*.52; const hand=point(lean+swing+air*20,shoulder.y-p.y+61-10*speed-15*air,side*(26+air*12)); + if(p.gunAmmo>0&&name+'-arm'===itemHand(p)){ + hand.x=upper.x+54;hand.y=upper.y+16;hand.z=upper.z; + } const elbow=joint(upper,hand,33,-1); bone(upper,elbow,name+'-upper-arm',name+'-arm');bone(elbow,hand,name+'-forearm',name+'-arm'); } @@ -15720,7 +15899,8 @@ function spiderDummyWorldGeometry(player, t) { } function runnerGeometry(player, t) { - return projectRunnerWorldGeometry(constrainLimbs(runnerWorldGeometry(player, t))); + const world=runnerWorldGeometry(player,t); + return projectRunnerWorldGeometry(poolOnly()?world:constrainLimbs(world)); } // Real bones, for drawing: every leg and arm is re-solved with fixed-length @@ -15773,6 +15953,10 @@ function constrainLimbs(world) { // its sideways offset at the pin's depth and rake across the frame. A bone // wholly behind the plane collapses onto the head, zero wide, so the draw // functions that look bones up by role still find every one of them. +function projectionScaleAt(point){ + const depth=cameraDoll.toView(point).z; + return lerp(cameraDoll.view.orthoScale,cameraDoll.view.focal/Math.max(cameraNear,depth),cameraDoll.perspective); +} function projectRunnerWorldGeometry(world) { const headView = cameraDoll.toView(world.head); const headPoint = cameraDoll.project(world.head); @@ -15781,7 +15965,7 @@ function projectRunnerWorldGeometry(world) { return { behind, head: { x: headPoint.x, y: headPoint.y, depth:headPoint.z, - radius: Math.max(1.5, world.head.radius * scale) }, + radius: world.head.radius * projectionScaleAt(world.head) }, segments: world.segments.map((segment) => { const cut = behind ? null : worldSegment(segment.x1, segment.y1, segment.z1, segment.x2, segment.y2, segment.z2); @@ -15790,7 +15974,8 @@ function projectRunnerWorldGeometry(world) { role: segment.role, part: segment.part, hitboxOnly: segment.hitboxOnly }; const { from: a, to: b } = cut; return { x1: a.x, y1: a.y, x2: b.x, y2: b.y, depth: (a.z + b.z) / 2, - width: Math.max(1.5, segment.width * scale), + width: segment.width * projectionScaleAt({x:(segment.x1+segment.x2)/2, + y:(segment.y1+segment.y2)/2,z:(segment.z1+segment.z2)/2}), role: segment.role, part: segment.part, hitboxOnly: segment.hitboxOnly }; }), @@ -17990,7 +18175,13 @@ function dummyView(player) { if (!view) dummyViews.set(player, view = {}); return Object.assign(view, player, dummyOverlay); } -function drawRunner(player, t, showLabel = true) { +function drawRunner(player,t,showLabel=true){ + const previous=figureDrawScale; + const world=player.replayGeometry||player.frozenGeometry||runnerWorldGeometry(player,t); + figureDrawScale=projectionScaleAt(world.head); + try{return drawRunnerAtScale(player,t,showLabel);}finally{figureDrawScale=previous;} +} +function drawRunnerAtScale(player, t, showLabel = true) { if(player.civilian && player.headless){drawCivilianDebris(player);return;} if(player.dummy)player=dummyView(player); // A fighter who is down is still on the stage: they read as the broken body @@ -19087,8 +19278,9 @@ function parkSurfaceColor(base,x,z){ return mixColor(base,[255,236,197],light*light*.18); } function drawPoolGeometry() { + drawParkParkingLot(); const ink=parkPalette.pool,ground=parkPalette.ground,rampInk=parkPalette.ramp; - const holes=parkPools.map(b=>({x:b.x,z:b.z,hx:b.halfX+b.radius+b.deck,hz:b.halfZ+b.radius+b.deck})).concat(parkRamps); + const holes=parkPools.map(b=>({x:b.x,z:b.z,hx:b.halfX+b.radius+b.deck,hz:b.halfZ+b.radius+b.deck})).concat(parkRamps,parkHalfPipe3D); // Include hole edges in the flat mesh so no triangle bridges a bowl. const cuts=(low,high,axis,half)=>{ const values=[low,high];for(let n=low+160;n[-x,x]))].sort((a,b)=>a-b); + const pz=[-pipe.hz,-700,-650,-600,-pipe.run,0,pipe.run,600,650,700,pipe.hz]; + const at=(x,z)=>({x:pipe.x+x,y:parkDeckY-parkHalfPipeHeight(pipe.x+x,pipe.z+z),z:pipe.z+z}); + for(let j=1;j[[-hx,-hz],[hx,-hz],[hx,hz],[-hx,hz]].map(([x,z])=>({x:r.x+x,y,z:r.z+z})); const base=corners(r.hx,r.hz,parkDeckY),top=corners(r.tx,r.tz,parkDeckY-r.height); @@ -19153,7 +19357,7 @@ function drawPoolGeometry() { } let pipeSurfaceMesh=null,pipeSurfaceKey=''; function drawTerrainSurface(left,right,near,far,color){ - if(poolOnly()){poolSurfaceMesh ||= captureQuadMesh(drawPoolGeometry);drawQuadMesh(poolSurfaceMesh);return;} + if(poolOnly()){poolSurfaceMesh ||= captureQuadMesh(drawPoolGeometry);drawParkScene(poolSurfaceMesh);return;} if(!indoorSkate()){drawTerrainSurfaceGeometry(left,right,near,far,color);return;} const key=gridCols+':'+near+':'+far+':'+color.join(','); if(key!==pipeSurfaceKey){pipeSurfaceKey=key;releaseQuadMesh(pipeSurfaceMesh);pipeSurfaceMesh=captureQuadMesh(()=>drawTerrainSurfaceGeometry(worldLeft,worldRight,near,far,color));} @@ -19241,13 +19445,62 @@ function drawTerrainBackWall(left, right, far, color) { terrainPass(left, right, wallZ, wallZ, wallBottom, () => wall); } +function drawParkParkingLot(){ + const l=gridLeft,r=gridLeft+gridWidth,n=worldNear,f=worldFar,m=parkLotMargin,y=parkDeckY; + const quad=(x1,z1,x2,z2,color,depth=0)=>worldQuad({x:x1,y:y-depth,z:z1},{x:x2,y:y-depth,z:z1}, + {x:x2,y:y-depth,z:z2},{x:x1,y:y-depth,z:z2},color); + for(const rect of [[l-m,n-m,r+m,n],[l-m,f,r+m,f+m],[l-m,n,l,f],[r,n,r+m,f]])quad(...rect,[76,83,94]); + for(const z of [n-800,f+220]){ + for(let x=l-1200;x=0;i--){const s=parkWindowShards[i];s.life-=dt; + if(s.life<=0){parkWindowShards.splice(i,1);continue;} + s.vy+=1200*dt;s.x+=s.vx*dt;s.y+=s.vy*dt;s.z+=s.vz*dt;s.rotation+=dt*4; + const floor=poolFloorAt(s.x,s.z)-1;if(s.y>floor){s.y=floor;s.vy=0;s.vx*=Math.exp(-dt*8);s.vz*=Math.exp(-dt*8);} + } +} +function drawParkWindowShards(){ + const saved=triangleDepth; + for(const s of parkWindowShards){const p=projectPoint(s.x,s.y,s.z);if(p.behind)continue; + const size=s.size*projectionScaleAt(s);triangleDepth=p.z; + screenTriangle(p.x-size,p.y,p.x+size,p.y-size*.3,p.x+Math.sin(s.rotation)*size,p.y-size,190,231,239); + }triangleDepth=saved; +} +const parkWindowMeshes=new Map(); +function drawParkWindows(){ + const top=parkDeckY-parkBuildingHeight,wt=top+260,wb=parkDeckY-470,midY=(wt+wb)/2; + for(const w of parkWindowWalls())for(let i=0;i{ + const l=i*w.width+100,r=(i+1)*w.width-100,mid=(l+r)/2; + const at=(u,y)=>({x:w.ax+w.ux*u,y,z:w.az+w.uz*u}); + const quad=(a,t,b,d,ink)=>worldQuad(at(a,t),at(b,t),at(b,d),at(a,d),ink); + for(const [a,b] of [[l+9,mid-9],[mid+9,r-9]]){ + quad(a,wt+9,b,midY-9,parkPalette.glass);quad(a,midY+9,b,wb-9,[196,228,231]);} + quad(mid-9,wt+9,mid+9,wb-9,parkPalette.frame); + quad(l+9,midY-9,r-9,midY+9,parkPalette.frame); + }); + for(const f of mesh.faces)if(f.color!==parkPalette.frame&&f.color.join(',')!==parkPalette.frame.join(',')) + f.normal=[-globalLight.x,-globalLight.y,-globalLight.z]; + parkWindowMeshes.set(key,mesh); + } + drawQuadMesh(mesh); + } +} let parkBuildingMesh=null; function drawParkBuildingGeometry(){ const p=parkPalette,top=parkDeckY-parkBuildingHeight; const left=gridLeft,right=gridLeft+gridWidth,near=worldNear,far=worldFar; // Wall panels, frame strips and daylight panes meet at their edges. // Overlaid trim would fight the wall's depth at shallow viewing angles. - const wall=(ax,az,bx,bz)=>{ + const wall=(ax,az,bx,bz,id)=>{ const length=Math.hypot(bx-ax,bz-az),ux=(bx-ax)/length,uz=(bz-az)/length; const nx=-uz,nz=ux,bays=Math.ceil(length/1100),width=length/bays; const quad=(l,t,r,b,color,depth=0)=>{ @@ -19259,20 +19512,15 @@ function drawParkBuildingGeometry(){ quad(a+24,top,b,wt-9,p.wall);quad(a+24,wb+9,b,parkDeckY-150,p.wall); quad(a+24,wt-9,l-9,wb+9,p.wall);quad(r+9,wt-9,b,wb+9,p.wall); quad(a+24,parkDeckY-150,b,parkDeckY,p.base); - // Each pane meets its frame without overlapping a second glass face. - const midX=(l+r)/2,midY=(wt+wb)/2; - for(const [x1,x2] of [[l+9,midX-9],[midX+9,r-9]]){ - quad(x1,wt+9,x2,midY-9,p.glass); - quad(x1,midY+9,x2,wb-9,[196,228,231]); - } - for(const x of [l,midX,r])quad(x-9,wt-9,x+9,wb+9,p.frame); - for(const y of [wt,midY,wb])quad(l+9,y-9,r-9,y+9,p.frame); + // Solid borders stay cached when the window's separate node breaks. + for(const x of [l,r])quad(x-9,wt-9,x+9,wb+9,p.frame); + for(const y of [wt,wb])quad(l+9,y-9,r-9,y+9,p.frame); quad(a,top,a+24,parkDeckY,p.frame); } }; // Traverse counter-clockwise so depth offsets face into the room. - wall(left,near,right,near);wall(right,near,right,far); - wall(right,far,left,far);wall(left,far,left,near); + wall(left,near,right,near,0);wall(right,near,right,far,1); + wall(right,far,left,far,2);wall(left,far,left,near,3); worldQuad({x:left,y:top,z:near},{x:right,y:top,z:near}, {x:right,y:top,z:far},{x:left,y:top,z:far},p.ceiling); const box3=(x1,y1,z1,x2,y2,z2,color,openBottom=false)=>{ @@ -19297,7 +19545,7 @@ function drawParkBuilding(){ for(const face of parkBuildingMesh.faces)if(face.color[0]===255||face.color.join(',')===parkPalette.glass.join(',')||face.color.join(',')==='196,228,231') face.normal=[-globalLight.x,-globalLight.y,-globalLight.z]; } - drawQuadMesh(parkBuildingMesh); + drawParkScene(parkBuildingMesh);drawParkWindows();drawParkWindowShards(); } function drawRoomSurfaces(left, right, top, bottom, color) { if(poolOnly()){drawParkBuilding();return;} @@ -20095,7 +20343,7 @@ function drawSaberPickup(pickup, t) { function drawBullet(bullet) { const previous = projectPoint(bullet.previousX ?? bullet.x, - bullet.previousY ?? bullet.y, bullet.z); + bullet.previousY ?? bullet.y, bullet.previousZ ?? bullet.z); const point = projectPoint(bullet.x, bullet.y, bullet.z); const blink = Math.floor(runtime().monotonicUs / 65000 + bullet.owner) % 2; // A bolt does not blink. The pistol round's alternating white is what makes @@ -21693,6 +21941,7 @@ function gamePaint() { kind: "player", item, x: item.x, y: item.y, z: item.z })), ]; if(freeskateActive())for(const item of parkKids){ + if(poolOnly()&&!parkActorVisible(item))continue; const p=projectPoint(item.x,item.y-90,item.z); if(p.x>-150 && p.x-200 && p.y=floor-2&&v.vy>=-1; if(supported){ @@ -22029,7 +22279,7 @@ function updatePoolLooseVehicle(v,dt,now,board){ } const oldY=v.y; v.x+=v.vx*step;v.z=(v.z||0)+(v.vz||0)*step; - boundParkBody(v); + boundParkBody(v,before); const nextFloor=poolFloorAt(v.x,v.z)-offset; v.vy+=1800*step;v.y+=v.vy*step; if(v.y>=nextFloor||(supported&&nextFloor-oldY<12)){v.y=nextFloor;v.vy=0;} @@ -22094,6 +22344,11 @@ function resetParkSupply(now){ if(halfpipeOnly()){ for(const p of [...gunPickups,...saberPickups,...grenadePickups])p.active=false; axePickup.active=false;for(const p of players){p.axeHeld=false;p.underPipe=false;} + if(poolOnly()){ + const pistol=gunPickups.find(p=>p.kind==='HANDGUN'); + Object.assign(pistol,{active:true,amount:12,x:pool.x+160,z:100,y:poolFloorAt(pool.x+160,100)-65}); + Object.assign(axePickup,{active:true,x:pool.x-170,z:-100,y:poolFloorAt(pool.x-170,-100)-65}); + } bottomGlass.fill(false);turboParticles.length=0; Object.assign(parkSupply,{nextAt:Infinity,drone:null,drop:null,ko:null}); return; @@ -22106,7 +22361,15 @@ function resetParkSupply(now){ for(const cube of hallCubes){cube.health=3;cube.broken=false;cube.hitAt=0;} } function updateParkSupply(dt,now){ - if(!freeskateActive() || halfpipeOnly())return; + if(!freeskateActive())return; + if(poolOnly()){ + if(axePickup.active)for(const p of activePlayers())if(p.alive&&availableArm(p)&& + Math.hypot(p.x-axePickup.x,(p.z||0)-(axePickup.z||0))<65&&Math.abs(p.y-axePickup.y-65)<100){ + p.axeHeld=true;p.itemArm=availableArm(p);axePickup.active=false;break; + } + return; + } + if(halfpipeOnly())return; if(parkSupply.ko){ if(!parkSupply.ko.settled){ const alive=activePlayers().filter(p=>p.alive),winner=alive.length===1?alive[0]:null; @@ -22130,8 +22393,8 @@ function updateParkSupply(dt,now){ } } function drawParkSupply(){ - if(!freeskateActive() || halfpipeOnly())return; - if(axePickup.active){const p=projectPoint(axePickup.x,axePickup.y,0),t=projectPoint(axePickup.x+30,axePickup.y-130,0);const old=triangleDepth;triangleDepth=p.z;drawBigAxe(p.x,p.y,t.x,t.y,cameraScale());triangleDepth=old;} + if(!freeskateActive() || (halfpipeOnly()&&!poolOnly()))return; + if(axePickup.active){const p=projectPoint(axePickup.x,axePickup.y,axePickup.z||0),t=projectPoint(axePickup.x+30,axePickup.y-130,axePickup.z||0);const old=triangleDepth;triangleDepth=p.z;drawBigAxe(p.x,p.y,t.x,t.y,cameraScale());triangleDepth=old;} const d=parkSupply.drone; if(d){ worldCapsule(d.x-65,d.y,0,d.x+65,d.y,0,Math.max(2,12*cameraScale()),[63,70,87]); @@ -22254,14 +22517,22 @@ function updateSkateAudio(dt){ if(skateAudioLevel<.0005)skateAudioLevel=0; if(!netSilent&&typeof skateAudio==='function')skateAudio(amount,skateAudioLevel); } -let motorFrequency=220,motorRunning=false; +let motorFrequency=75,motorRunning=false,motorSpeed=0; function updateMotorAudio(dt){ if(typeof oscillator!=='function')return; - const p=activePlayers().find(p=>p.alive&&p.onewheel); - if(!p){if(motorRunning&&typeof oscillatorStop==='function')oscillatorStop();motorRunning=false;return;} - const speed=clamp(Math.abs(p.skateVx||0)/3000,0,1); - motorFrequency+=(220+speed*900-motorFrequency)*(1-Math.exp(-dt*6)); - oscillator(motorFrequency,.018+speed*.055);motorRunning=true; + const p=activePlayers().find(p=>p.alive&&p.onewheel&&p.skateboard); + const speed=p?Math.hypot(p.vx,p.vz||0):0; + if(!p||!p.grounded||speed<(motorRunning?12:28)||netSilent){ + if(motorRunning){if(typeof oscillatorStop==='function')oscillatorStop();else oscillator(motorFrequency,0);} + motorRunning=false;motorSpeed=speed;return; + } + const amount=clamp(speed/3200,0,1),load=clamp(Math.abs(speed-motorSpeed)/Math.max(dt,.001)/5000,0,1); + motorSpeed=speed; + // Low electric hum with a soft acceleration whine; no standing idle tone. + const target=65+Math.pow(amount,.72)*280+load*28; + motorFrequency+=(target-motorFrequency)*(1-Math.exp(-Math.max(0,dt)*8)); + const gain=(.008+Math.sqrt(amount)*.042+load*.01)*clamp((speed-12)/90,0,1); + oscillator(motorFrequency,gain);motorRunning=true; } function axeFrame(p,t){ if(!p.axeHeld || !p.alive || isHeadOnly(p) || !availableArm(p))return null; @@ -22270,7 +22541,7 @@ function axeFrame(p,t){ const hand={x:arm.x2,y:arm.y2,z:arm.z2??p.z??0}; let dx=arm.x2-arm.x1,dy=-Math.abs(arm.y2-arm.y1),dz=(arm.z2||0)-(arm.z1||0); const length=Math.hypot(dx,dy,dz)||1;const up={x:dx/length,y:dy/length,z:dz/length}; - const yaw=p.spin?.angle||0,side={x:Math.cos(yaw),y:0,z:Math.sin(yaw)}; + const yaw=(p.spin?.angle||0)+(poolOnly()?p.poolYaw||0:0),side={x:Math.cos(yaw),y:0,z:Math.sin(yaw)}; const dot=up.x*side.x+up.z*side.z; side.x-=up.x*dot;side.y-=up.y*dot;side.z-=up.z*dot; const norm=Math.hypot(side.x,side.y,side.z)||1;side.x/=norm;side.y/=norm;side.z/=norm; @@ -22431,12 +22702,82 @@ function meshHandle(mesh){ if(mesh.nativeHandle===undefined){const b=nativeMeshBuffers(mesh);mesh.nativeHandle=nativeMeshUpload(b.vertices,b.faces,b.capsules);} return mesh.nativeHandle; } +// Static park geometry is partitioned once. Each leaf owns a small retained +// mesh and is rejected before projection or a native bridge call. +function sceneBoundsVisible(bounds){ + if(!bounds)return true; + const corners=[]; + for(const x of [bounds.minX,bounds.maxX])for(const y of [bounds.minY,bounds.maxY]) + for(const z of [bounds.minZ,bounds.maxZ])corners.push(cameraDoll.toView({x,y,z})); + if(corners.every(p=>p.zp.zcameraDoll.projectView(p)); + const left=clipView?.x??0,right=clipView?clipView.x+clipView.w:viewWidth(); + const top=clipView?.y??0,bottom=clipView?clipView.y+clipView.h:viewHeight; + return !(points.every(p=>p.xp.x>right+24)|| + points.every(p=>p.yp.y>bottom+24)); +} +function buildParkScene(mesh){ + const entries=mesh.faces.map(face=>{ + const points=face.ids.map(i=>mesh.vertices[i]); + return {face,center:{x:points.reduce((s,p)=>s+p.x,0)/4,y:points.reduce((s,p)=>s+p.y,0)/4,z:points.reduce((s,p)=>s+p.z,0)/4}}; + }); + const build=items=>{ + const bounds={minX:Infinity,minY:Infinity,minZ:Infinity,maxX:-Infinity,maxY:-Infinity,maxZ:-Infinity}; + for(const {face} of items)for(const i of face.ids){const p=mesh.vertices[i]; + for(const [k,lo,hi] of [['x','minX','maxX'],['y','minY','maxY'],['z','minZ','maxZ']]){ + bounds[lo]=Math.min(bounds[lo],p[k]);bounds[hi]=Math.max(bounds[hi],p[k]);}} + if(items.length>64){ + const span=k=>Math.max(...items.map(e=>e.center[k]))-Math.min(...items.map(e=>e.center[k])); + const axis=['x','y','z'].sort((a,b)=>span(b)-span(a))[0]; + items.sort((a,b)=>a.center[axis]-b.center[axis]);const half=Math.ceil(items.length/2); + return {bounds,children:[build(items.slice(0,half)),build(items.slice(half))]}; + } + const leaf={vertices:[],faces:[],projected:[],capsules:[],bounds},indices=new Map(); + for(const {face} of items)leaf.faces.push({...face,lit:[],ids:face.ids.map(i=>{ + if(!indices.has(i)){indices.set(i,leaf.vertices.length);leaf.vertices.push(mesh.vertices[i]);leaf.projected.push({});} + return indices.get(i); + })}); + return {bounds,mesh:leaf}; + }; + return build(entries); +} +function drawParkScene(mesh){ + if(!mesh)return; + mesh.sceneRoot ||= buildParkScene(mesh); + const visit=node=>{if(!sceneBoundsVisible(node.bounds))return; + if(node.children)for(const child of node.children)visit(child);else drawQuadMesh(node.mesh);}; + visit(mesh.sceneRoot); + // Coping rails are few and clip independently; their widths remain physical. + for(const a of mesh.capsules||[])worldCapsule(a[0],a[1],a[2],a[3],a[4],a[5],a[6]*cameraScale(),a[7],a[8]); +} +function parkSightlineClear(from,to){ + for(const {w,bay,u,fraction} of parkWallContact(from,to,0)){ + const y=lerp(from.y,to.y,fraction); + if(!brokenParkWindows.has(w.id+':'+bay)||u<=bay*w.width+109||u>=(bay+1)*w.width-109|| + y<=parkDeckY-parkBuildingHeight+269||y>=parkDeckY-479)return false; + } + for(let i=1;i<12;i++){ + const t=i/12,x=lerp(from.x,to.x,t),z=lerp(from.z,to.z,t); + if(lerp(from.y,to.y,t)>poolFloorAt(x,z)+8)return false; + } + return true; +} +function parkActorVisible(p){ + const size=p.civilian?(p.bodyScale||1):1; + if(!sceneBoundsVisible({minX:p.x-150*size,maxX:p.x+150*size,minY:p.y-240*size,maxY:p.y+30, + minZ:(p.z||0)-150*size,maxZ:(p.z||0)+150*size}))return false; + return [165,85,10].some(height=>parkSightlineClear(cameraDoll.position,{x:p.x,y:p.y-height*size,z:p.z||0})); +} function releaseQuadMesh(mesh){ + if(mesh?.sceneRoot){const release=node=>{if(node.children)node.children.forEach(release);else releaseQuadMesh(node.mesh);}; + release(mesh.sceneRoot);mesh.sceneRoot=null;} if(mesh&&mesh.nativeHandle>=0&&nativeMeshFree)nativeMeshFree(mesh.nativeHandle); if(mesh)mesh.nativeHandle=-1; } function drawQuadMesh(mesh){ - if(!mesh)return; + if(!mesh||!sceneBoundsVisible(mesh.bounds))return; if(nativeRetainedMeshes){ const handle=meshHandle(mesh); if(handle>=0){nativeMeshDraw(handle,mainNativeCamera(),clipView?0:cameraScale());return;} @@ -22448,14 +22789,6 @@ function drawQuadMesh(mesh){ if(!mesh.bounds && mesh.vertices.length){ const v=mesh.vertices;mesh.bounds={minX:Math.min(...v.map(p=>p.x)),maxX:Math.max(...v.map(p=>p.x)),minY:Math.min(...v.map(p=>p.y)),maxY:Math.max(...v.map(p=>p.y)),minZ:Math.min(...v.map(p=>p.z)),maxZ:Math.max(...v.map(p=>p.z))}; } - if(mesh.bounds){ - const b=mesh.bounds,corners=[]; - for(const x of [b.minX,b.maxX])for(const y of [b.minY,b.maxY])for(const z of [b.minZ,b.maxZ])corners.push(cameraDoll.toView({x,y,z})); - if(corners.every(p=>p.z>=cameraNear)){ - const projected=corners.map(p=>cameraDoll.projectView(p)); - if(projected.every(p=>p.x<0)||projected.every(p=>p.x>viewWidth())||projected.every(p=>p.y<0)||projected.every(p=>p.y>viewHeight))return; - } - } const meshScale=cameraScale(); if(!clipView)for(const args of mesh.capsules||[])worldCapsule(args[0],args[1],args[2],args[3],args[4],args[5],args[6]*meshScale,args[7],args[8]); if(programBuffered){ @@ -22533,7 +22866,7 @@ function drawSpinArms(player,t,geometry,front,color,outline){ const parkKids=[]; function resetParkKids(){ parkKids.length=0; - if(!indoorSkate())return; + if(!indoorSkate()&&!poolOnly())return; const skin=[[255,214,194],[198,139,104],[118,78,61],[239,185,142],[160,111,90],[255,222,199],[213,160,122],[144,96,76]]; const inks=[[237,105,119],[84,183,172],[244,179,67],[129,139,222],[180,105,183],[92,159,208],[216,134,81],[149,184,95]]; for(let i=0;i<8;i++){ @@ -22552,6 +22885,11 @@ function resetParkKids(){ headBustedAt:0,frozenGeometry:null,replayGeometry:null,fallenBodyGeometry:null, attackKind:'',attackUntil:0,resultReaction:null,underPipe:false,swimming:false, patrolDirection:i%2?1:-1,patrolMin:tileCenterX(11+i*7),patrolMax:tileCenterX(16+i*7)}); + if(poolOnly()){ + const x=850+i*850,z=i%2?-850:1000; + Object.assign(kid,{x,z,y:poolFloorAt(x,z),spawnX:x,patrolMin:x-180,patrolMax:x+180, + patrolZ:z,poolYaw:kid.patrolDirection>0?0:Math.PI,poolStridePhase:i*.13}); + } parkKids.push(kid); } } @@ -22560,21 +22898,23 @@ function updateParkKids(dt,now){ for(const kid of parkKids){ if(kid.headless){ const h=kid.looseHead;h.vy+=1100*dt;h.x+=h.vx*dt;h.y+=h.vy*dt;h.z=clamp(h.z+h.vz*dt,worldNear+30,worldFar-30); - const floor=terrainFloorAt(h.x)-h.radius; + const floor=terrainFloorAt(h.x,h.z)-h.radius; if(h.y>floor){h.y=floor;h.vy=-Math.abs(h.vy)*.35;h.vx*=Math.exp(-dt*3);h.vz*=Math.exp(-dt*3);} continue; } - kid.z=Math.sin(now/3500000+kid.pad*1.2)*140; + kid.z=(kid.patrolZ||0)+Math.sin(now/3500000+kid.pad*1.2)*(poolOnly()?35:140); if(kid.x>=kid.patrolMax)kid.patrolDirection=-1; if(kid.x<=kid.patrolMin)kid.patrolDirection=1; - const danger=activePlayers().some(p=>!p.dummy && p.alive && Math.abs(p.x-kid.x)<230 && Math.abs(p.y-kid.y)<240 && (Math.abs(p.vx)>250 || p.spin)); + const danger=activePlayers().some(p=>!p.dummy && p.alive && Math.hypot(p.x-kid.x,(p.z||0)-kid.z)<230 && Math.abs(p.y-kid.y)<240 && (Math.abs(p.vx)>250 || p.spin)); if(danger && now>=(kid.nextStartleAt||0)){ kid.startledUntil=now+900000;kid.nextStartleAt=now+3000000; if(now>=crowdVoiceAt){crowdVoiceAt=now+350000;for(let i=0;i<5;i++)crowdNotes.push({at:now+i*35000,hz:680+kid.pad*35+Math.sin(i*.8)*330});} } kid.ducking=now<(kid.startledUntil||0);kid.crouchBlend+=(Number(kid.ducking)-(kid.crouchBlend||0))*Math.min(1,dt*14); kid.facing=kid.patrolDirection;kid.vx=kid.ducking?0:kid.patrolDirection*95*(.92+(kid.pad%5)*.045)*(1+Math.sin((kid.breathPhase||0)*Math.PI*2)*.06); - const previous=kid.x;kid.x+=kid.vx*dt;kid.y=terrainFloorAt(kid.x); + const previous=kid.x;kid.x+=kid.vx*dt;kid.y=terrainFloorAt(kid.x,kid.z); + if(poolOnly()){kid.poolYaw=kid.patrolDirection>0?0:Math.PI;kid.facing=1; + kid.poolStridePhase=(kid.poolStridePhase||0)+Math.abs(kid.vx)*dt/240;} resolveCubeSides(kid,previous); if(kid.wallPress)kid.patrolDirection*=-1; kid.grounded=true;kid.inputX=kid.patrolDirection; diff --git a/xbox/live/tests/pool-megatexture.test.mjs b/xbox/live/tests/pool-megatexture.test.mjs new file mode 100644 index 0000000000..47d7d12d8a --- /dev/null +++ b/xbox/live/tests/pool-megatexture.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +const source = await readFile(new URL('../oskiewar.js', import.meta.url), 'utf8'); + +test('a decal-only native host bakes pool marks once and draws one retained surface', () => { + const calls = { stamps: 0, uploads: 0, draws: 0, clears: 0 }; + const api = new Function('decalClear', 'decalStamp', 'decalMeshUpload', 'decalMesh', ` + const noop=()=>{}; + let runtime=()=>({monotonicUs:0}),capabilities=()=>({platform:'macos-native'}), + wipe=noop,box=noop,line=noop,triangle=noop,write=noop,systemWrite=noop; + ${source} + return { configureWorldMap, clearPoolDecals, addDecal, drawDecals, + state:()=>({marks:poolDecalCount,geometry:decals.length}) }; + `)(() => { calls.clears++; return true; }, (...stamp) => { + assert.equal(stamp.length, 12); assert.ok(stamp.every(Number.isFinite)); calls.stamps++; return true; + }, (vertices, faces) => { + assert.ok(vertices instanceof Float32Array && faces instanceof Float32Array); + assert.ok(vertices.length > 0 && faces.length > 0); calls.uploads++; return 0; + }, (handle, camera, ...bounds) => { + assert.equal(handle, 0); assert.equal(camera.length, 27); assert.equal(bounds.length, 4); calls.draws++; + }); + api.configureWorldMap('skatepark', 'pool'); + api.clearPoolDecals(); + for (let i = 0; i < 1800; i++) api.addDecal({ kind:'wheelmark', x:900+i%60*5, + z:-150+Math.floor(i/60)*10, size:3, angle:0, strength:1 }); + const stamps = calls.stamps; + assert.ok(stamps >= 1800); + assert.deepEqual(api.state(), { marks:1800, geometry:0 }); + for (let i=0;i<60;i++) api.drawDecals([180,180,180]); + assert.equal(calls.stamps, stamps, 'old marks are never resubmitted'); + assert.equal(calls.uploads, 1, 'the pool mesh crosses the bridge once'); + assert.equal(calls.draws, 60, 'one surface draw per frame regardless of mark count'); + api.clearPoolDecals(); + api.drawDecals([180,180,180]); + assert.equal(calls.draws, 60, 'clearing the map removes its marks'); +}); diff --git a/xbox/live/tests/pool-playground.test.mjs b/xbox/live/tests/pool-playground.test.mjs new file mode 100644 index 0000000000..ddbb47de0a --- /dev/null +++ b/xbox/live/tests/pool-playground.test.mjs @@ -0,0 +1,174 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +const source=await readFile(new URL('../oskiewar.js',import.meta.url),'utf8'); +function playground(){ + let now=1e6;const noop=()=>{},audio=[]; + const api=new Function('runtime','capabilities','telemetry','gameSignal','drum','wipe','box','line','triangle','write','systemWrite','oscillator','oscillatorStop',`${source} + configureWorldMap('skatepark','pool');fightOpponent='freeskate';gameMode='fight'; + return {players,updatePlayer,runnerWorldGeometry,projectRunnerWorldGeometry,cameraDoll, + parkHalfPipe3D,parkHalfPipeHeight,parkDeckY,poolFloorAt,poolSlopeAt,gunPickups,axePickup, + resetParkSupply,updateParkSupply,updateGunPickups,resetParkKids,updateParkKids,parkKids, + bullets,updateBullets,gunPose,drawPoolGeometry,captureQuadMesh,drawRunner, + boundParkBody,parkWindowWalls,brokenParkWindows,parkWindowShards,resetParkWindows, + breakParkWindow,updateParkWindowShards,insidePark,parkLotMargin,updateCameraDoll,clearPoolCamera,updateMotorAudio, + sceneBoundsVisible,buildParkScene,parkActorVisible,figureLod, + state:()=>({halfpipe:parkHalfPipe3D})};`)(()=>({monotonicUs:now}),()=>({platform:'web'}),noop,noop,noop,noop,noop,noop,noop,noop,noop,(hz,gain)=>audio.push({hz,gain}),()=>audio.push({stop:true})); + const p=api.players[0];Object.assign(p,{x:1080,z:0,y:api.poolFloorAt(1080,0),grounded:true,alive:true,dummy:false,skateboard:false,poolYaw:0,previous:[],spin:null,directionChanges:[],poolLastSteer:0}); + api.step=(down=[],dt=1/60)=>{now+=dt*1e6;api.updatePlayer(p,{down,leftX:0,leftY:0},dt,now);}; + api.now=()=>now;api.audio=audio;return api; +} +test('pool flicks start, sustain and release a spin on foot and on a board',()=>{ + for(const board of [false,true]){ + const a=playground(),p=a.players[0];p.skateboard=board; + for(const key of ['ArrowLeft','ArrowRight','ArrowLeft'])a.step([key]); + assert.ok(p.spin,'three flicks restore the spin'); + const angle=p.spin.angle,rate=p.spin.rate; + a.step();assert.notEqual(p.spin.angle,angle);assert.ok(p.spin.raterate,'another flick adds momentum'); + for(let n=0;n<1200&&p.spin;n++)a.step(); + assert.equal(p.spin,null,'friction settles the spin'); + } +}); +test('3D rig preserves connected limb lengths through headings, spins and civilian scale',()=>{ + const a=playground(),p=a.players[0]; + for(const board of [false,true])for(const bank of [0,.25])for(const yaw of [0,.7,Math.PI/2,Math.PI])for(const spin of [null,{angle:1.2,rate:12,direction:1}]){ + Object.assign(p,{skateboard:board,poolYaw:yaw,spin,civilian:true,bodyScale:.83,poolLean:bank}); + const pose=a.runnerWorldGeometry(p,0); + for(const upper of pose.segments){ + const leg=/thigh$/.test(upper.role||''),arm=/upper-arm$/.test(upper.role||'');if(!leg&&!arm)continue; + const lower=pose.segments.find(s=>s.part===upper.part&&(leg?/shin$/:/forearm$/).test(s.role||''));if(!lower)continue; + const length=s=>Math.hypot(s.x2-s.x1,s.y2-s.y1,s.z2-s.z1); + assert.ok(Math.abs(length(upper)-(leg?48:33)*.83)<1e-6,`${board} ${yaw} ${upper.role}: ${length(upper)}`); + assert.ok(Math.abs(length(lower)-(leg?47:32)*.83)<1e-6); + for(const k of ['x','y','z'])assert.ok(Math.abs(upper[k+'2']-lower[k+'1'])<1e-6); + } + } +}); +test('head and limb thickness shrink with perspective distance',()=>{ + const a=playground();a.cameraDoll.snap({position:{x:0,y:0,z:0},target:{x:0,y:0,z:1},width:1800,perspective:1}); + const geometry=z=>a.projectRunnerWorldGeometry({head:{x:0,y:0,z,radius:22},segments:[{x1:0,y1:30,z1:z,x2:0,y2:100,z2:z,width:10}]}); + const near=geometry(500),far=geometry(1000); + assert.ok(Math.abs(near.head.radius/far.head.radius-2)<1e-9); + assert.ok(Math.abs(near.segments[0].width/far.segments[0].width-2)<1e-9); +}); +test('half-pipe has a flat, circular transitions, decks, and open roll-ins matching its mesh',()=>{ + const a=playground(),p=a.parkHalfPipe3D; + assert.equal(a.parkHalfPipeHeight(p.x,p.z),0); + assert.equal(a.parkHalfPipeHeight(p.x+p.flat+p.radius,p.z),p.radius); + assert.equal(a.poolFloorAt(p.x+p.flat+p.radius,p.z),a.parkDeckY-p.radius); + assert.equal(a.parkHalfPipeHeight(p.x+p.flat+p.radius,p.z+p.hz),0); + assert.ok(a.poolSlopeAt(p.x+p.flat+p.radius*.6,p.z).x<0); + const mesh=a.captureQuadMesh(a.drawPoolGeometry); + const vertices=mesh.vertices.filter(v=>Math.abs(v.x-p.x)<=p.hx&&Math.abs(v.z-p.z)<=p.hz); + assert.ok(vertices.length>100); + for(const v of vertices)assert.ok(Math.abs(v.y-a.poolFloorAt(v.x,v.z))<1e-6,JSON.stringify(v)); +}); +test('pistol and axe are collectible in 3D, and pistol shoots along the rider heading',()=>{ + const a=playground(),p=a.players[0];a.resetParkSupply(a.now()); + const gun=a.gunPickups.find(g=>g.kind==='HANDGUN');assert.ok(gun.active&&a.axePickup.active); + Object.assign(p,{x:gun.x,z:gun.z+500,y:gun.y+65});a.updateGunPickups(a.now());assert.ok(gun.active,'no pickup across depth'); + p.z=gun.z;a.updateGunPickups(a.now());assert.ok(p.gunAmmo>0);assert.equal(gun.active,false); + Object.assign(p,{x:a.axePickup.x,z:a.axePickup.z,y:a.axePickup.y+65});a.updateParkSupply(1/60,a.now());assert.ok(p.axeHeld); + p.poolYaw=Math.PI/2;const ammo=p.gunAmmo;a.step(['Y']);assert.equal(p.gunAmmo,ammo-1);assert.equal(a.bullets.length,1); + const shot=a.bullets[0],z=shot.z;assert.ok(Math.abs(shot.vx)<1e-6&&shot.vz>0); + a.updateBullets(1/60,a.now(),false);assert.ok(shot.z>z);assert.equal(shot.previousZ,z); +}); +test('eight civilians walk the pool park surface without attacking',()=>{ + const a=playground();a.resetParkKids();assert.equal(a.parkKids.length,8); + for(let n=0;n<120;n++)a.updateParkKids(1/60,a.now()+n*1e6/60); + for(const kid of a.parkKids){assert.equal(kid.y,a.poolFloorAt(kid.x,kid.z));assert.equal(kid.attackKind,'');assert.ok(Number.isFinite(kid.poolStridePhase));} +}); +test('a board rides a half-pipe transition and lands without tunneling',()=>{ + const a=playground(),p=a.players[0],pipe=a.parkHalfPipe3D; + Object.assign(p,{x:pipe.x,z:0,y:a.poolFloorAt(pipe.x,0),vx:1900,vz:0,vy:0,skateboard:true,onewheel:false,poolYaw:0}); + let rise=0,air=false,landed=false; + for(let n=0;n<420;n++){ + const wasAir=!p.grounded;a.step(); + rise=Math.max(rise,a.parkDeckY-p.y);air ||= !p.grounded;landed ||= wasAir&&p.grounded; + assert.ok([p.x,p.y,p.z,p.vx,p.vy,p.vz].every(Number.isFinite)); + assert.ok(p.y<=a.poolFloorAt(p.x,p.z)+.01,'rider stays above the surface'); + } + assert.ok(rise>pipe.radius*.75,`transition climbed ${rise}`); + assert.ok(air&&landed,'vert air returns to the park surface'); +}); +test('a jumping rider breaks glass, exits to the lot and returns through that opening',()=>{ + const a=playground(),p=a.players[0],w=a.parkWindowWalls()[0],x=w.width*.5,z=w.az; + Object.assign(p,{x,y:a.parkDeckY-600,z:z+50,vx:0,vz:-1200,vy:0,grounded:false}); + const before={x,z:p.z};p.z=z-20;a.boundParkBody(p,before); + assert.equal(a.brokenParkWindows.size,1);assert.ok(p.z0); + const outside={x,z:p.z};p.z=z+20;p.vz=500;a.boundParkBody(p,outside);assert.ok(p.z>z,'return through same hole'); + p.z=z-500;p.y=a.parkDeckY;p.vz=0;a.boundParkBody(p,{x,z:p.z});assert.equal(p.z,z-500,'lot is playable'); + for(let n=0;n<180;n++)a.updateParkWindowShards(1/60);assert.equal(a.parkWindowShards.length,0); + a.resetParkWindows();assert.equal(a.brokenParkWindows.size,0); +}); +test('walls, window frames and unopened glass remain solid',()=>{ + const a=playground(),p=a.players[0],w=a.parkWindowWalls()[0],z=w.az; + for(const [x,y,speed] of [[w.width*.5,a.parkDeckY,1200],[50,a.parkDeckY-600,1200],[w.width*.5,a.parkDeckY-600,100]]){ + Object.assign(p,{x,y,z:z-10,vx:0,vz:-speed,spin:null,attackKind:''}); + a.boundParkBody(p,{x,z:z+40});assert.ok(p.z>=z+25);assert.equal(a.brokenParkWindows.size,0); + } +}); +test('shots break window glass and continue into the lot',()=>{ + const a=playground(),w=a.parkWindowWalls()[0]; + a.bullets.push({x:w.width*.5,y:a.parkDeckY-700,z:w.az+10,vx:0,vy:0,vz:-4200,life:1,owner:0,safeUntil:Infinity}); + a.updateBullets(1/60,a.now(),false);assert.equal(a.brokenParkWindows.size,1);assert.ok(a.bullets[0].z{ + const a=playground(),p=a.players[0];Object.assign(p,{x:2300,z:1700,y:a.poolFloorAt(2300,1700),vx:0,vz:0,vy:0,poolYaw:0}); + for(let i=0;i<180;i++)a.updateCameraDoll(1/60,a.now()); + assert.ok(a.cameraDoll.target.x-p.x>200,'frame leads the rider'); + assert.ok(p.y-a.cameraDoll.position.y<300,'lower than the old 345-unit boom'); + const subject={x:600,y:a.parkDeckY,z:-3500}; + const camera=a.clearPoolCamera({x:600,y:a.parkDeckY-220,z:-2800},subject); + assert.ok(camera.z<-3000,'camera remains outside with the rider'); +}); +test('monowheel audio is silent at rest, quieter while rolling, and stops on dismount',()=>{ + const a=playground(),p=a.players[0];Object.assign(p,{onewheel:true,skateboard:true,grounded:true,vx:0,vz:0,skateVx:2400}); + a.updateMotorAudio(1/60);assert.equal(a.audio.length,0,'stale skate speed cannot sustain a tone'); + p.vx=1400;for(let n=0;n<60;n++)a.updateMotorAudio(1/60); + assert.ok(a.audio.at(-1).hz<350&&a.audio.at(-1).gain<.07); + p.vx=0;a.updateMotorAudio(1/60);assert.equal(a.audio.at(-1).stop,true); + const calls=a.audio.length;a.updateMotorAudio(1/60);assert.equal(a.audio.length,calls,'no idle restart'); + p.vx=1400;a.updateMotorAudio(1/60);p.onewheel=false;a.updateMotorAudio(1/60);assert.equal(a.audio.at(-1).stop,true); +}); +test('normal jump and forward input can leave the building and land in the lot',()=>{ + const a=playground(),p=a.players[0],w=a.parkWindowWalls()[0]; + Object.assign(p,{x:w.width*.5,z:w.az+300,y:a.parkDeckY,poolYaw:-Math.PI/2,vx:0,vz:-1400,vy:0,grounded:true}); + a.step(['A','ArrowUp']);for(let i=0;i<180;i++)a.step(['ArrowUp']); + assert.ok(a.brokenParkWindows.size>0,'jump impact breaks a window'); + assert.ok(p.z{ + const a=playground(),mesh=a.captureQuadMesh(a.drawPoolGeometry),root=a.buildParkScene(mesh); + let faces=0,leaves=0;const seen=[]; + const visit=node=>{if(node.children){node.children.forEach(visit);return;} + leaves++;assert.ok(node.mesh.faces.length<=64);faces+=node.mesh.faces.length; + for(const face of node.mesh.faces)seen.push(face.ids.map(i=>node.mesh.vertices[i]).map(v=>`${v.x},${v.y},${v.z}`).join('|')); + };visit(root);assert.equal(faces,mesh.faces.length);assert.ok(leaves>10); + const original=mesh.faces.map(f=>f.ids.map(i=>mesh.vertices[i]).map(v=>`${v.x},${v.y},${v.z}`).join('|')); + assert.deepEqual(seen.sort(),original.sort()); +}); +test('scene bounds cull behind-camera and off-screen objects but preserve near-plane crossings',()=>{ + const a=playground();a.cameraDoll.snap({position:{x:0,y:0,z:0},target:{x:0,y:0,z:1},width:1800,perspective:1}); + const box=(x,z,r=20)=>({minX:x-r,maxX:x+r,minY:-r,maxY:r,minZ:z-r,maxZ:z+r}); + assert.equal(a.sceneBoundsVisible(box(0,500)),true); + assert.equal(a.sceneBoundsVisible(box(0,-500)),false); + assert.equal(a.sceneBoundsVisible(box(10000,500)),false); + assert.equal(a.sceneBoundsVisible(box(0,10,30)),true); +}); +test('civilian occlusion respects intact walls and broken window openings',()=>{ + const a=playground(),w=a.parkWindowWalls()[0],x=w.width*.5; + const p={x,z:w.az+180,y:a.parkDeckY-550,civilian:true,bodyScale:1}; + a.cameraDoll.snap({position:{x,y:p.y-100,z:w.az-500},target:{x,y:p.y-100,z:p.z},width:1800,perspective:1}); + assert.equal(a.parkActorVisible(p),false); + a.breakParkWindow(w,0,p);assert.equal(a.parkActorVisible(p),true); +}); +test('figure LOD follows projected size and holds its tier at a boundary',()=>{ + const a=playground(),p={},geometry=h=>({head:{x:0,y:0,radius:5},segments:[{x1:0,y1:5,x2:0,y2:h-5,width:3}]}); + assert.equal(a.figureLod(p,geometry(150)),0); + assert.equal(a.figureLod(p,geometry(61)),0,'hysteresis prevents near-threshold flicker'); + assert.equal(a.figureLod(p,geometry(40)),1); + assert.equal(a.figureLod(p,geometry(20)),2); + assert.equal(a.figureLod(p,geometry(8)),3); +}); diff --git a/xbox/macos-native/PoolDecals.cpp b/xbox/macos-native/PoolDecals.cpp new file mode 100644 index 0000000000..999cb9e427 --- /dev/null +++ b/xbox/macos-native/PoolDecals.cpp @@ -0,0 +1,83 @@ +// Mac binding for the same fixed pool megatexture used by the Xbox host. +#include "../runtime/include/ac/decal_surface.hpp" +#include +#include +#include + +namespace { +struct Point { double x,y,z,u=0,v=0,q=1; }; +struct Pool { + ac::xbox::DecalSurface surface; + std::vector vertices, faces, projected; +}; +Point mix(Point a,Point b,double t) { + return {a.x+(b.x-a.x)*t,a.y+(b.y-a.y)*t,a.z+(b.z-a.z)*t, + a.u+(b.u-a.u)*t,a.v+(b.v-a.v)*t,a.q+(b.q-a.q)*t}; +} +template std::vector clip(const std::vector& in,Distance distance) { + std::vector out; + for(size_t i=0;i=0)out.push_back(a); + if((da>=0)!=(db>=0))out.push_back(mix(a,b,da/(da-db))); + } + return out; +} +} +extern "C" { +void* ac_pool_create() { return new Pool; } +void ac_pool_destroy(void* p) { delete static_cast(p); } +void ac_pool_clear(void* p) { static_cast(p)->surface.clear(); } +bool ac_pool_stamp(void* p,const float* values) { + std::array q;std::copy_n(values,12,q.begin()); + return static_cast(p)->surface.stamp(q); +} +// Return the dirty rectangle without consuming it; Swift copies it into each +// frame slot's pending bounds before acknowledging it with ac_pool_clean. +const uint8_t* ac_pool_pixels(void* p,int* bounds) { + const auto& s=static_cast(p)->surface; + if(!s.dirty)return nullptr; + bounds[0]=s.left;bounds[1]=s.top;bounds[2]=s.right;bounds[3]=s.bottom; + return s.pixels.data(); +} +const uint8_t* ac_pool_all_pixels(void* p) { return static_cast(p)->surface.pixels.data(); } +void ac_pool_clean(void* p) { static_cast(p)->surface.clean(); } +bool ac_pool_mesh(void* p,const float* v,int nv,const float* f,int nf) { + if(nv<=0||nv%3||nv>300000||nf<=0||nf%10||nf>100000)return false; + for(int i=0;i=nv/3||std::floor(f[i+j])!=f[i+j])return false; + auto& s=*static_cast(p);s.vertices.assign(v,v+nv);s.faces.assign(f,f+nf);return true; +} +// Six floats per projected vertex: x, y, depth, u/depth, v/depth, 1/depth. +// Clip both position and texture coordinates, as the Xbox decalMesh does. +const float* ac_pool_draw(void* p,const float* m,const float* bounds,int* count) { + *count=0; + for(int i=0;i<27;i++)if(!std::isfinite(m[i]))return nullptr; + for(int i=0;i<4;i++)if(!std::isfinite(bounds[i]))return nullptr; + if(m[17]<=0||m[18]>=m[20]||m[19]>=m[21]||bounds[2]<=0||bounds[3]<=0)return nullptr; + auto& s=*static_cast(p);s.projected.clear();std::vector view; + for(size_t i=0;i poly;for(int j=0;j<4;j++)poly.push_back(view[size_t(f[j])]); + poly=clip(poly,[&](Point a){return a.z-m[17];}); + for(auto& a:poly) { + double k=m[14]+(m[15]/a.z-m[14])*m[16],q=m[16]>.999?1/a.z:1; + a={m[12]+a.x*k,m[13]-a.y*k,std::max(-1.499,std::clamp(double(m[22])+a.z*m[23],-1.499,1.4)-.0005),a.u*q,a.v*q,q}; + } + poly=clip(poly,[&](Point a){return a.x-m[18];}); + poly=clip(poly,[&](Point a){return m[20]-a.x;}); + poly=clip(poly,[&](Point a){return a.y-m[19];}); + poly=clip(poly,[&](Point a){return m[21]-a.y;}); + for(size_t j=1;j+1 +#include +#ifdef __cplusplus +extern "C" { +#endif +void* ac_pool_create(void); +void ac_pool_destroy(void*); +void ac_pool_clear(void*); +bool ac_pool_stamp(void*, const float*); +const uint8_t* ac_pool_pixels(void*, int*); +const uint8_t* ac_pool_all_pixels(void*); +void ac_pool_clean(void*); +bool ac_pool_mesh(void*, const float*, int, const float*, int); +const float* ac_pool_draw(void*, const float*, const float*, int*); +#ifdef __cplusplus +} +#endif diff --git a/xbox/macos-native/PoolDecals.swift b/xbox/macos-native/PoolDecals.swift new file mode 100644 index 0000000000..a3d2e09c0e --- /dev/null +++ b/xbox/macos-native/PoolDecals.swift @@ -0,0 +1,72 @@ +import JavaScriptCore +import MetalKit + +/// One logical 2048² pool texture. Three GPU snapshots follow the renderer's +/// in-flight slots; each receives only the dirty bounds accumulated since use. +final class PoolDecals { + private let surface = ac_pool_create()! + private weak var scene: MetalSceneView? + private var textures: [MTLTexture] = [] + private var pending = [CGRect?](repeating: nil, count: 3) + + init(scene: MetalSceneView) { + self.scene = scene + let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm, + width: 2048, height: 2048, mipmapped: false) + descriptor.storageMode = scene.device?.hasUnifiedMemory == false ? .managed : .shared + descriptor.usage = .shaderRead + if let device = scene.device { + for _ in 0..<3 { if let texture = device.makeTexture(descriptor: descriptor) { textures.append(texture) } } + } + scene.poolDecalTexture = { [weak self] slot in self?.texture(slot) } + } + deinit { ac_pool_destroy(surface) } + + func clear() -> Bool { + guard textures.count == 3 else { return false } + ac_pool_clear(surface) + return true + } + func stamp(_ values: [Float]) -> Bool { + guard values.count == 12 else { return false } + return values.withUnsafeBufferPointer { ac_pool_stamp(surface, $0.baseAddress!) } + } + func upload(_ vertices: [Float], _ faces: [Float]) -> Int { + guard !vertices.isEmpty, !faces.isEmpty else { return -1 } + return vertices.withUnsafeBufferPointer { v in faces.withUnsafeBufferPointer { f in + ac_pool_mesh(surface, v.baseAddress!, Int32(v.count), f.baseAddress!, Int32(f.count)) ? 0 : -1 + } } + } + func draw(handle: Int, camera: [Float], bounds: [Float]) -> Int { + guard handle == 0, camera.count == 27, bounds.count == 4, let scene else { return 0 } + var count: Int32 = 0 + return camera.withUnsafeBufferPointer { m in bounds.withUnsafeBufferPointer { b in + guard let values = ac_pool_draw(surface, m.baseAddress!, b.baseAddress!, &count), count > 0 else { return 0 } + scene.setPoolDecals(values, vertexCount: Int(count)) + return Int(count) / 3 + } } + } + private func texture(_ slot: Int) -> MTLTexture? { + guard textures.count == 3, textures.indices.contains(slot) else { return nil } + var bounds = [Int32](repeating: 0, count: 4) + if ac_pool_pixels(surface, &bounds) != nil { + let dirty = CGRect(x: Int(bounds[0]), y: Int(bounds[1]), + width: Int(bounds[2] - bounds[0]), height: Int(bounds[3] - bounds[1])) + for i in pending.indices { pending[i] = pending[i]?.union(dirty) ?? dirty } + ac_pool_clean(surface) + } + if let dirty = pending[slot], !dirty.isEmpty, let pixels = ac_pool_all_pixels(surface) { + let x = Int(dirty.minX), y = Int(dirty.minY) + textures[slot].replace(region: MTLRegionMake2D(x, y, Int(dirty.width), Int(dirty.height)), + mipmapLevel: 0, withBytes: pixels + (y * 2048 + x) * 4, bytesPerRow: 2048 * 4) + pending[slot] = nil + } + return textures[slot] + } + + static func floats(_ value: JSValue, limit: Int) -> [Float] { + let count = Int(value.forProperty("length")?.toInt32() ?? 0) + guard count > 0, count <= limit else { return [] } + return (0..= 1 { self.oscillatorPhase -= 1 } let angle = self.oscillatorPhase * 2 * Double.pi - // A sine with a quieter octave gives the wheel a little grit. - let sample = Float((sin(angle) + 0.35 * sin(angle * 2)) * self.oscillatorGain) + // A low fundamental with restrained motor harmonics. + let sample = Float((sin(angle) + 0.18 * sin(angle * 2) + 0.06 * sin(angle * 3)) * self.oscillatorGain) for buffer in buffers { buffer.mData?.assumingMemoryBound(to: Float.self)[frame] = sample } @@ -617,6 +617,7 @@ private final class NativeGameHost { private var paintCount = 0 private var runtimeSeconds = CACurrentMediaTime() private var javascriptError = "" + private let telemetryQueue = DispatchQueue(label: "computer.aesthetic.oskiewar.telemetry", qos: .utility) // Opt-in frame accounting. The number that matters for a tvOS port is what // `sim` costs without a JIT, since third-party apps get JavaScriptCore in // interpreter mode on tvOS. Run with OSKIEWAR_PERF=1 and again under @@ -625,6 +626,7 @@ private final class NativeGameHost { private var perfSimSeconds = 0.0 private var perfPaintSeconds = 0.0 private var perfFrames = 0 + private var perfFrameDeltas: [Double] = [] init(view: GameView) { self.view = view @@ -776,6 +778,7 @@ private final class NativeGameHost { let delta = min(0.1, max(0, now - lastFrame)) lastFrame = now accumulator += delta + if perf { perfFrameDeltas.append(delta) } let fixed = 1.0 / 60.0 var steps = 0 while accumulator >= fixed && steps < 6 { @@ -815,14 +818,18 @@ private final class NativeGameHost { guard perfFrames >= 60 else { return } let sim = perfSimSeconds / Double(perfFrames) * 1000 let paint = perfPaintSeconds / Double(perfFrames) * 1000 + let intervals = perfFrameDeltas.sorted() + let fps = Double(perfFrames) / max(0.001, perfFrameDeltas.reduce(0, +)) + let p95 = intervals[min(intervals.count - 1, Int(Double(intervals.count) * 0.95))] * 1000 let jit = ProcessInfo.processInfo.environment["JSC_useJIT"] ?? "(default)" print(String(format: - "oskiewar perf · sim %.2f ms · paint %.2f ms · js %.2f ms of 16.67 · JSC_useJIT=%@", - sim, paint, sim + paint, jit)) + "oskiewar perf · %.1f fps · frame p95 %.2f ms · sim %.2f ms · paint %.2f ms · js %.2f ms of 16.67 · JSC_useJIT=%@", + fps, p95, sim, paint, sim + paint, jit)) fflush(stdout) perfSimSeconds = 0 perfPaintSeconds = 0 perfFrames = 0 + perfFrameDeltas.removeAll(keepingCapacity: true) } private func updateLogicalSize() { @@ -930,8 +937,9 @@ private final class NativeGameHost { "height": Int(self?.renderer.logicalSize.height ?? 1080), ] as NSDictionary } - let telemetry: @convention(block) (String, String) -> Void = { kind, detail in - NSLog("oskiewar %@ %@", kind, detail) + let telemetry: @convention(block) (String, String) -> Void = { [weak self] kind, detail in + // Trace batches can be large; console I/O must not hold the frame. + self?.telemetryQueue.async { NSLog("oskiewar %@ %@", kind, detail) } } let drum: @convention(block) (String, Double, Double) -> Void = { [weak self] name, velocity, pan in self?.audio.drum(name, velocity: velocity, pan: pan) @@ -972,6 +980,23 @@ private final class NativeGameHost { javascript.setObject(gamepad, forKeyedSubscript: "gamepad" as NSString) javascript.setObject(controllers, forKeyedSubscript: "controllers" as NSString) javascript.setObject(capabilities, forKeyedSubscript: "capabilities" as NSString) + let poolDecals = PoolDecals(scene: view.scene) + let clearDecals: @convention(block) () -> Bool = { poolDecals.clear() } + let stampDecal: @convention(block) () -> Bool = { + let values = (JSContext.currentArguments() as? [JSValue] ?? []).map { Float($0.toDouble()) } + return poolDecals.stamp(values) + } + let uploadDecalMesh: @convention(block) (JSValue, JSValue) -> Int = { vertices, faces in + poolDecals.upload(PoolDecals.floats(vertices, limit: 300000), PoolDecals.floats(faces, limit: 100000)) + } + let drawDecalMesh: @convention(block) (Int, JSValue, Double, Double, Double, Double) -> Int = { handle, camera, x, z, width, depth in + poolDecals.draw(handle: handle, camera: PoolDecals.floats(camera, limit: 27), + bounds: [Float(x), Float(z), Float(width), Float(depth)]) + } + javascript.setObject(clearDecals, forKeyedSubscript: "decalClear" as NSString) + javascript.setObject(stampDecal, forKeyedSubscript: "decalStamp" as NSString) + javascript.setObject(uploadDecalMesh, forKeyedSubscript: "decalMeshUpload" as NSString) + javascript.setObject(drawDecalMesh, forKeyedSubscript: "decalMesh" as NSString) // The retained-texture contract. Arguments arrive loose (the sprite's // depthWrite is optional), so these read the call's own argument list. let numbers: () -> [Double] = { diff --git a/xbox/macos-native/tests/pool-decals.cpp b/xbox/macos-native/tests/pool-decals.cpp new file mode 100644 index 0000000000..950d155a47 --- /dev/null +++ b/xbox/macos-native/tests/pool-decals.cpp @@ -0,0 +1,50 @@ +#include "../PoolDecals.h" +#include +#include +#include +#include +#include + +int main() { + void* pool=ac_pool_create(); + int dirty[4];const auto* pixels=ac_pool_pixels(pool,dirty); + assert(pixels&&dirty[0]==0&&dirty[2]==2048); + ac_pool_clean(pool);assert(!ac_pool_pixels(pool,dirty)); + float stamp[]={0,0,128,128,100,100,132,100,132,132,100,132}; + assert(ac_pool_stamp(pool,stamp));pixels=ac_pool_pixels(pool,dirty); + assert(dirty[0]==100&&dirty[1]==100&&dirty[2]==132&&dirty[3]==132); + unsigned alpha=0;for(int y=100;y<132;y++)for(int x=100;x<132;x++)alpha+=pixels[(y*2048+x)*4+3]; + assert(alpha>0); + const auto* originalPixels=ac_pool_all_pixels(pool); + ac_pool_clean(pool); + stamp[0]=250;assert(!ac_pool_stamp(pool,stamp));assert(!ac_pool_pixels(pool,dirty));stamp[0]=0; + const float vertices[]={-10,0,20,10,0,20,10,0,40,-10,0,40}; + const float faces[]={0,1,2,3,255,255,255,0,-1,0}; + assert(ac_pool_mesh(pool,vertices,12,faces,10)); + float badFaces[]={0,1,2,99,255,255,255,0,-1,0}; + assert(!ac_pool_mesh(pool,vertices,12,badFaces,10)); + // A pitched camera with the rear edge beyond the near plane. + float camera[]={0,-10,0,1,0,0,0,-1,0,0,0,1,100,100,1,100,1,25,0,0,200,200,-1.4f,.000175f,0,-1,0}; + const float bounds[]={-10,20,20,20};int count=0; + const float* drawn=ac_pool_draw(pool,camera,bounds,&count); + assert(drawn&&count>=3&&count%3==0); + const std::vector before(drawn,drawn+count*6); + for(int i=0;i=0&&v[0]<=200&&v[1]>=0&&v[1]<=200); + assert(v[5]>0&&v[5]<=1.f/25+1e-6); + assert(v[3]/v[5]>=-1e-5&&v[3]/v[5]<=1.00001); + assert(v[4]/v[5]>=-1e-5&&v[4]/v[5]<=1.00001); + } + // More marks change pixels, never mesh cost or the retained raster size. + for(int i=0;i<10000;i++)assert(ac_pool_stamp(pool,stamp)); + assert(ac_pool_all_pixels(pool)==originalPixels); + drawn=ac_pool_draw(pool,camera,bounds,&count); + assert(std::vector(drawn,drawn+count*6)==before); + camera[17]=100;ac_pool_draw(pool,camera,bounds,&count);assert(count==0); + camera[0]=std::numeric_limits::quiet_NaN();assert(!ac_pool_draw(pool,camera,bounds,&count)&&count==0); + ac_pool_clear(pool);pixels=ac_pool_all_pixels(pool); + for(int y=100;y<132;y++)for(int x=100;x<132;x++)assert(pixels[(y*2048+x)*4+3]==0); + ac_pool_destroy(pool); + std::cout << "Pool megatexture: bounded storage, persistent stamps, clipped UVs, constant draw geometry passed\n"; +} diff --git a/xbox/tools/build-macos-app.sh b/xbox/tools/build-macos-app.sh index 89ecedbaba..312a316e6d 100755 --- a/xbox/tools/build-macos-app.sh +++ b/xbox/tools/build-macos-app.sh @@ -14,7 +14,11 @@ mkdir -p "$contents/MacOS" "$contents/Resources/live" # apple/oskiewar — one renderer, compiled into both. main.swift keeps its # top-level code as the entry point, which stays legal as long as it is the # file literally named main.swift. -swiftc -swift-version 5 -O "$repo_root/xbox/macos-native/main.swift" \ +object_dir=$(mktemp -d) +trap 'rm -rf "$object_dir"' EXIT +clang++ -std=c++17 -O2 -c "$repo_root/xbox/macos-native/PoolDecals.cpp" -o "$object_dir/PoolDecals.o" +swiftc -swift-version 5 -O -import-objc-header "$repo_root/xbox/macos-native/PoolDecals.h" \ + "$repo_root/xbox/macos-native/PoolDecals.swift" "$object_dir/PoolDecals.o" -lc++ "$repo_root/xbox/macos-native/main.swift" \ "$repo_root/apple/oskiewar/Sources/MetalSceneView.swift" \ "$repo_root/apple/oskiewar/Sources/GlyphAtlas.swift" \ -framework AppKit -framework AVFoundation -framework CoreVideo \ -- 2.51.2