From 97834bb23a03956feb6fce8bbb99dbc2f7c1c8dc Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 27 Sep 2026 14:48:56 -0700 Subject: [PATCH] oskiewar v178: bake persistent pool marks at constant frame cost --- xbox/live/oskiewar.js | 73 ++++++++++++++----- xbox/live/tests/xbox-feedback.test.mjs | 53 +++++++++++++- xbox/native-bios/App.cpp | 60 ++++++++++++--- xbox/native-bios/Package.appxmanifest | 2 +- xbox/native-bios/QuickJsEngine.cpp | 16 ++++ xbox/native-bios/README.md | 10 +++ xbox/native-bios/ScenePrimitives.inc | 40 +++++++++- .../tests/quickjs_engine_smoke.cpp | 42 ++++++++++- xbox/runtime/include/ac/decal_surface.hpp | 54 ++++++++++++++ xbox/runtime/include/ac/runtime.hpp | 4 + 10 files changed, 321 insertions(+), 33 deletions(-) create mode 100644 xbox/runtime/include/ac/decal_surface.hpp diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index 5c398e921d..7a790aef0a 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 = 177; +const buildVersion = 178; 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 @@ -420,6 +420,7 @@ function configureWorldMap(name, course = skateCourse) { // cleared only when the map itself changes. if (previousMap !== skateparkMap + "/" + skateCourse) { decals.length = 0; + clearPoolDecals(); bloodDrops.length = 0; } const indoor = skateparkMap && skateCourse === "indoor"; @@ -892,6 +893,10 @@ function drawRasterDecal(decal) { } } function drawDecals(surface) { + if(poolOnly()&&poolDecalsNative&&poolDecalCount){ + poolSurfaceMesh ||= captureQuadMesh(drawPoolGeometry); + decalMesh(meshHandle(poolSurfaceMesh),mainNativeCamera(),gridLeft,worldNear,gridWidth,roomDepth); + } if (!decals.length && !bloodDrops.length) return; const now = runtime().monotonicUs; const reach = cameraWidth * .8 + 300; @@ -3076,6 +3081,12 @@ let impactSequence = 0; const decals = []; const bloodDrops = []; const decalCap = 160; +let poolDecalsNative=null,poolDecalCount=0; +function clearPoolDecals(){ + poolDecalCount=0; + poolDecalsNative=typeof decalClear==='function'&&typeof decalStamp==='function'&& + typeof decalMesh==='function'&&nativeRetainedMeshes?decalClear():false; +} let decalSeed = 7; function decalRandom() { decalSeed = (Math.imul(decalSeed, 1103515245) + 12345) >>> 0; @@ -3090,6 +3101,16 @@ function addDecal(decal) { const slope = terrainTangentAt(decal.x, 8), norm = Math.hypot(1, slope); decal.tx = 1 / norm; decal.ty = slope / norm; decal.shape = decalShape(decal); + if(poolOnly()){ + if(poolDecalsNative===null)clearPoolDecals(); + 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])); + poolDecalCount++; + return decal; + } + } // Blood landing in blood makes a pool rather than another mark: the // splat it lands in grows (and freshens), so the count stays flat however // much is spilled. @@ -3267,7 +3288,7 @@ function addSkid(x1, x2, z, strength = 1) { addDecal({ kind: "skid", x: x1, x2, z, size: 3.2, strength }); } function seedHalfpipeScuffs() { - if (!halfpipeOnly() || decals.length) return; + if (!halfpipeOnly() || decals.length || poolDecalCount) return; for (const [x,z,size,angle] of [[380,-140,65,.2],[510,160,48,-.3], [820,200,45,.1],[1160,-80,38,.5],[1500,-170,65,-.2],[1730,90,55,.4]]) addDecal({kind:"scuff",x,z,size,angle,stretch:1.7,strength:.5}); @@ -9456,8 +9477,8 @@ function updateCameraDoll(dt, now) { z:lerp(p.z||0,lookZ,.15+poolCameraDip*.4)}; const subject={x:p.x,y:p.y-2,z:p.z||0}; const position=clearPoolCamera({x:target.x-Math.cos(angle)*distance, - y:Math.min(p.y-85-(880+poolCameraDip*260+Math.min(500,airHeight*.2))*zoom-playerCameraPitch*600, - floorY-pool.radius-300),z:target.z-Math.sin(angle)*distance},subject); + y:p.y-85-(lerp(390,880,clamp(airHeight/500,0,1))+poolCameraDip*230+Math.min(500,airHeight*.2))*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); // Also protect the eased path while orbiting around a corner of the pool. @@ -12162,6 +12183,8 @@ function updatePoolPlayer(p, pad, dt, now) { const turnRate=p.onewheel?lerp(2.8,1.9,clamp(speedBefore/3000,0,1)):3.2; // Steering changes the rider's heading. Forward always pushes along that heading. p.poolYaw=oldHeading-turn*turnRate*dt; + const bank=turn*(p.skateboard?.34:.18)*clamp(speedBefore/1400,0,1); + p.poolLean=((p.poolLean||0)+(bank-(p.poolLean||0))*(1-Math.exp(-dt*8))); const headingDelta=p.poolYaw-oldHeading; if(p.grounded&&headingDelta){ const vx=p.vx,vz=p.vz||0,c=Math.cos(headingDelta),sn=Math.sin(headingDelta); @@ -14941,6 +14964,25 @@ function runnerWorldGeometry(player, t) { segment.x1 = a.x; segment.y1 = a.y; segment.x2 = b.x; segment.y2 = b.y; } } + if(poolOnly()&&!isHeadOnly(player)){ + const z=player.z||0,bank=player.poolLean||0; + if(!player.skateboard){ + 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; + bone.z1=z+side*width;bone.z2=z+side*width; + if(bone.role==='shoulders'){bone.z1=z-22;bone.z2=z+22;} + } + } + // Bank the torso into a carve while the feet remain planted on the deck. + const lean=(point,yKey,zKey)=>{ + const height=Math.max(0,player.y-point[yKey]-28),weight=clamp(height/60,0,1); + point[zKey]=(point[zKey]??z)-height*Math.sin(bank)*weight; + point[yKey]+=height*(1-Math.cos(bank))*weight; + }; + lean(pose.head,'y','z'); + for(const bone of pose.segments){lean(bone,'y1','z1');lean(bone,'y2','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; @@ -14994,7 +15036,7 @@ function updateRig(player, pad, dt) { if (rig.drip > .7) { rig.drip = 0; spawnBlood(player.x, player.y - 60, player.z || 0, 0, 1, .2); } } rig.lastGrounded = player.grounded; - const crouchTarget = (pumping ? .55 : 0) + (!player.grounded && board ? .3 : 0); + const crouchTarget = (pumping ? .55 : poolOnly()&&board?.28:0) + (!player.grounded && board ? .3 : 0); springStep(rig, "crouch", crouchTarget, 90, 9, dt); rig.crouch = clamp(rig.crouch, 0, .95); springStep(rig, "arms", clamp((rig.leanV || 0) * .02, -1.4, 1.4) * strength, 55, 4, dt); @@ -15487,12 +15529,12 @@ function buildRunnerWorldGeometry(player, t, at = null) { Object.assign(head, transform(head.x, head.y, 0)); for (const bone of segments) { const side = bone.part.startsWith("left-") ? -1 : bone.part.startsWith("right-") ? 1 : 0; - const depth = side * (bone.part.includes("arm") ? 15 : 9); + const depth = side * (bone.part.includes("arm") ? (poolOnly()?27:15) : (poolOnly()?14:9)); const a = transform(bone.x1, bone.y1, depth); const b = transform(bone.x2, bone.y2, depth); if (bone.role === "shoulders") { - a.z = z - 15; - b.z = z + 15; + a.z = z - (poolOnly()?27:15); + b.z = z + (poolOnly()?27:15); } Object.assign(bone, { x1: a.x, y1: a.y, z1: a.z, x2: b.x, y2: b.y, z2: b.z }); } @@ -15501,8 +15543,8 @@ function buildRunnerWorldGeometry(player, t, at = null) { ["rear", -player.facing, rearLeg]]) { if (!hasPart(player, part)) continue; const side = part === "left-leg" ? -1 : 1; - const hip = transform(x, hipY, side * 7); - const foot = deck(direction * skateTruck, -4, side * 9); + const hip = transform(x, hipY, side * (poolOnly()?12:7)); + const foot = deck(direction * skateTruck, -4, side * (poolOnly()?14:9)); const dx = foot.x - hip.x, dy = foot.y - hip.y, dz = foot.z - hip.z; const distance = Math.hypot(dx, dy, dz) || 1; const length = Math.max(48, distance / 1.98); @@ -18945,7 +18987,7 @@ function terrainSurfaceShades(color) { } let poolSurfaceMesh=null; function drawPoolGeometry() { - const center=gridLeft+gridWidth/2,segments=12; + const center=gridLeft+gridWidth/2,segments=12,ink=[172,212,211]; const ring=(radius,y)=>{ const points=[]; for(let corner=0;corner<4;corner++)for(let i=0;i<=segments;i++){ @@ -18959,7 +19001,7 @@ function drawPoolGeometry() { for(let x=-pool.halfX;x=bands-2&&band<=bands; - const shade=band>bands?[216,221,211]:tile?(i%2?[37,117,151]:[49,139,167]): - (i+band)%2?[153,197,199]:[167,210,211]; - worldQuad(previous[i],previous[j],current[j],current[i],shade); + worldQuad(current[i],current[j],previous[j],previous[i],ink); } previous=current; } for(let i=0;i ({ connected: true, down: [], leftX: 0, leftY: 0 })); const context = vm.createContext({ __oskiewarFreeskateMap: course, @@ -22,6 +23,10 @@ function game(course = "pool") { drum: (...args) => sounds.push(args), themeAssetReady: asset => asset === 5, themeQuad: (...args) => { decalsDrawn.push(args); return true; }, + ...(retainedDecals?{meshUpload:()=>0,meshDraw:()=>0, + decalClear:()=>{surface.clears++;return true;}, + decalStamp:()=>{surface.stamps++;return true;}, + decalMesh:()=>{surface.draws++;return 2000;}}:{}), ...Object.fromEntries(['telemetry', 'gameSignal', 'saveReplay', 'publishLive', 'analytics', 'wipe', 'box', 'line', 'triangle', 'write'].map(key => [key, () => {}])), }); @@ -31,6 +36,8 @@ function game(course = "pool") { cameraDoll,steerPostEffects,projectPoint,drawSeatPlayerHud,handleWidth, breakRoofPane,configureWorldMap,resetRoofPanes,updatePlayer,updateWheelTurbo, addDecal,decals,rasterDecalPatches,terrainFloorAt,drawDecals, + surfaceState:()=>({native:poolDecalsNative,count:poolDecalCount}), + poolMesh:()=>captureQuadMesh(drawPoolGeometry), pool,poolDistance,poolSlopeAt,monowheelFrame,runnerWorldGeometry,updateCameraDoll, debug:()=>debugHitboxes=true, bounds:()=>({near:worldNear+60,far:worldFar-60}), @@ -48,7 +55,7 @@ function game(course = "pool") { api.boot(); api.begin(); assert.equal(api.error(), ''); - return { api, context, post, texts, faces, sounds, pads, decalsDrawn, + return { api, context, post, texts, faces, sounds, pads, decalsDrawn, surface, tick: () => { now += 16667; api.sim(); assert.equal(api.error(), ''); } }; } @@ -265,6 +272,48 @@ test('pool marks remain visible beyond both old limits, after time and revisitin assert.equal(a.decals.length,0,'changing the actual map starts a new surface'); }); +test('retained pool marks stamp once and keep constant draw work after ten thousand marks', () => { + const g=game('pool',true),a=g.api,clears=g.surface.clears; + assert.equal(a.surfaceState().native,true); + a.drawDecals([172,212,211]);const oneFrame=g.surface.draws; + const old=a.surfaceState().count; + for(let i=0;i<10000;i++)a.addDecal({kind:'skid',x:700+i%400,x2:720+i%400,z:0,size:7}); + assert.equal(a.surfaceState().count,old+10000); + assert.equal(a.decals.length,0,'no growing JS decal history or patch cache'); + assert.equal(g.surface.clears,clears,'new marks never clear the texture'); + const stamps=g.surface.stamps; + a.drawDecals([172,212,211]); + assert.equal(g.surface.draws,oneFrame+1,'one retained mesh submission per frame'); + assert.equal(g.surface.stamps,stamps,'painting never replays old stamps'); + assert.equal(g.decalsDrawn.length,0,'does not consume the 1024 theme-quad budget'); + a.reset();assert.equal(g.surface.clears,clears); + assert.equal(a.surfaceState().count,old+10000,'reset does not reseed or erase'); + a.configureWorldMap('skatepark','indoor');assert.equal(g.surface.clears,clears+1); +}); + +test('the bowl uses one solid material and the grounded camera follows lower', () => { + const g=game(),a=g.api; + const mesh=a.poolMesh();assert.equal(new Set(mesh.faces.map(f=>f.color.join(','))).size,1); + for(let i=0;i<120;i++)g.tick(); + assert.ok(a.cameraDoll.position.y>1100,'lens is lower than the old overhead view'); + assert.ok(a.cameraDoll.position.y { + const g=game(),a=g.api,p=a.players[0]; + Object.assign(p,{skateboard:true,onewheel:true,vx:1800,poolYaw:0,poolLean:0}); + const straight=a.runnerWorldGeometry(p,0); + const shoulders=straight.segments.find(b=>b.role==='shoulders'); + assert.ok(Math.abs(shoulders.z2-shoulders.z1)>=50,'shoulders span real depth'); + p.poolLean=.3; + const banked=a.runnerWorldGeometry(p,0); + assert.ok(banked.head.z/shin$/.test(b.role))){ + const after=banked.segments.find(b=>b.role===foot.role); + assert.ok(Math.abs(after.z2-foot.z2)<.1,'feet stay planted while the torso banks'); + } +}); + test('a glass roof break is one composite cue and still works on the full hall', () => { const {api:a,sounds} = game(); a.configureWorldMap('skatepark','indoor'); a.resetRoofPanes(); diff --git a/xbox/native-bios/App.cpp b/xbox/native-bios/App.cpp index 796ad46847..ac26fe7877 100644 --- a/xbox/native-bios/App.cpp +++ b/xbox/native-bios/App.cpp @@ -7,6 +7,7 @@ #include "../runtime/include/ac/image_effects.hpp" #include "../runtime/include/ac/glass_sound.hpp" #include "../runtime/include/ac/decal_atlas.hpp" +#include "../runtime/include/ac/decal_surface.hpp" #include "../runtime/include/ac/theme_assets.hpp" #include "render/ac_surface.hpp" #if AC_DEV_LIVE_PIECE @@ -251,6 +252,9 @@ class HostGraphics final : public Graphics { std::function is_theme_ready; std::function is_theme_asset_ready; std::function on_theme_quad; + std::function on_decal_clear; + std::function&)> on_decal_stamp; + std::function on_decal_triangle; std::function on_write; std::function on_system_write; std::function on_system_glyph; @@ -273,6 +277,9 @@ class HostGraphics final : public Graphics { void theme_quad(const ac::xbox::ThemeQuad& quad) override { if (on_theme_quad) on_theme_quad(quad); } + bool decal_clear() override { return on_decal_clear && on_decal_clear(); } + bool decal_stamp(const std::array& stamp) override { return on_decal_stamp && on_decal_stamp(stamp); } + void decal_triangle(const ac::xbox::TexturedTriangle& triangle) override { if(on_decal_triangle)on_decal_triangle(triangle); } void theme_sprite(const ac::xbox::ThemeSprite& sprite) override { const float c = std::cos(sprite.angle), s = std::sin(sprite.angle); const float hx = sprite.width * .5f, hy = sprite.height * .5f; @@ -401,6 +408,16 @@ public: m_graphics->on_theme_quad = [this](const ac::xbox::ThemeQuad& quad) { if (m_frameThemeQuads.size() < kMaxThemeQuads) m_frameThemeQuads.push_back(quad); }; + m_graphics->on_decal_clear = [this]() { + if(!m_decalView || !m_themePixelShader)return false; + m_decalSurface.clear();return true; + }; + m_graphics->on_decal_stamp = [this](const std::array& stamp) { + return m_decalView && m_decalSurface.stamp(stamp); + }; + m_graphics->on_decal_triangle = [this](const ac::xbox::TexturedTriangle& triangle) { + if(m_frameDecalTriangles.size()on_write = [this](const ac::xbox::Text& text) { m_frameTexts.push_back(text); }; m_graphics->on_system_write = [this](const ac::xbox::SystemText& text) { if (m_frameSystemTexts.size() + m_frameSystemGlyphs.size() < kMaxSystemDraws) @@ -575,6 +592,7 @@ public: m_frameLines.clear(); m_frameTriangles.clear(); m_frameTexturedTriangles.clear(); + m_frameDecalTriangles.clear(); m_frameSprites.clear(); m_frameThemeQuads.clear(); m_frameTexts.clear(); @@ -923,7 +941,7 @@ private: vertexBytes.size(), &m_spriteInputLayout)); D3D11_BUFFER_DESC buffer{}; buffer.ByteWidth = static_cast((std::max)(kMaxSprites * 6, - kMaxTexturedTriangles * 3) * sizeof(GpuSpriteVertex)); + kMaxDecalTriangles * 3) * sizeof(GpuSpriteVertex)); buffer.Usage = D3D11_USAGE_DYNAMIC; buffer.BindFlags = D3D11_BIND_VERTEX_BUFFER; buffer.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; @@ -990,7 +1008,13 @@ private: ComPtr decalTexture; Check(m_device->CreateTexture2D(&texture, &pixels, &decalTexture)); Check(m_device->CreateShaderResourceView(decalTexture.Get(), nullptr, &m_themeViews[5])); - LogTelemetry("AC_NATIVE_DECALS ready=1 atlas=256x256 stamps=4 alpha=1"); + texture.Width=texture.Height=DecalSurface::side; + texture.Usage=D3D11_USAGE_DEFAULT; + pixels.pSysMem=m_decalSurface.pixels.data();pixels.SysMemPitch=DecalSurface::side*4; + Check(m_device->CreateTexture2D(&texture,&pixels,&m_decalTexture)); + Check(m_device->CreateShaderResourceView(m_decalTexture.Get(),nullptr,&m_decalView)); + m_decalSurface.clean(); + LogTelemetry("AC_NATIVE_DECALS ready=1 atlas=256x256 surface=2048x2048 retained=1 alpha=1"); m_frameThemeQuads.reserve(kMaxThemeQuads); LogTelemetry(std::string("AC_NATIVE_THEME ready=") + (m_themeViews[0] && m_themeViews[1] ? "1" : "0") + @@ -1051,10 +1075,19 @@ private: return true; } - bool DrawGpuTexturedTriangles() { - if (m_frameTexturedTriangles.empty()) return true; + bool DrawGpuTexturedTriangles(bool decals=false) { + const auto& triangles=decals?m_frameDecalTriangles:m_frameTexturedTriangles; + auto* view=decals?m_decalView.Get():m_jeffreyTextureView.Get(); + if (triangles.empty()) return true; + if(decals && m_decalSurface.dirty && m_decalTexture){ + const auto& surface=m_decalSurface; + const D3D11_BOX box{surface.left,surface.top,0,surface.right,surface.bottom,1}; + m_context->UpdateSubresource(m_decalTexture.Get(),0,&box, + surface.pixels.data()+(surface.top*DecalSurface::side+surface.left)*4,DecalSurface::side*4,0); + m_decalSurface.clean(); + } if (!m_spriteVertexBuffer || !m_spriteVertexShader || !m_spritePixelShader || - !m_jeffreyTextureView || !m_linearSampler || !m_triangleDepthView) return false; + !view || !m_linearSampler || !m_triangleDepthView) return false; D3D11_MAPPED_SUBRESOURCE mapped{}; if (FAILED(m_context->Map(m_spriteVertexBuffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped))) return false; @@ -1066,7 +1099,7 @@ private: (std::max)(0.f, (std::min)(1.f, (z + 1.5f) / 3.f)), u, v, color.r / 255.f, color.g / 255.f, color.b / 255.f, color.a / 255.f}; }; - for (const auto& triangle : m_frameTexturedTriangles) { + for (const auto& triangle : triangles) { append(triangle.x1, triangle.y1, triangle.z1, triangle.u1, triangle.v1, triangle.color); append(triangle.x2, triangle.y2, triangle.z2, triangle.u2, triangle.v2, @@ -1080,15 +1113,17 @@ private: m_context->IASetVertexBuffers(0, 1, m_spriteVertexBuffer.GetAddressOf(), &stride, &offset); m_context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); m_context->VSSetShader(m_spriteVertexShader.Get(), nullptr, 0); - m_context->PSSetShader(m_spritePixelShader.Get(), nullptr, 0); - m_context->PSSetShaderResources(0, 1, m_jeffreyTextureView.GetAddressOf()); + m_context->PSSetShader(decals?m_themePixelShader.Get():m_spritePixelShader.Get(), nullptr, 0); + m_context->PSSetShaderResources(0, 1, &view); m_context->PSSetSamplers(0, 1, m_linearSampler.GetAddressOf()); m_context->RSSetState(m_triangleRasterState.Get()); - m_context->OMSetDepthStencilState(m_triangleDepthState.Get(), 1); + m_context->OMSetDepthStencilState(decals?m_themeSoftDepthState.Get():m_triangleDepthState.Get(), 1); + m_context->OMSetBlendState(decals?m_themeBlendState.Get():nullptr,nullptr,0xffffffff); m_context->OMSetRenderTargets(1, m_sceneTarget.GetAddressOf(), m_triangleDepthView.Get()); m_context->Draw(static_cast(count), 0); ID3D11ShaderResourceView* nullView = nullptr; m_context->PSSetShaderResources(0, 1, &nullView); + m_context->OMSetBlendState(nullptr,nullptr,0xffffffff); return true; } @@ -2724,6 +2759,7 @@ private: DrawGpuTexturedTriangles(); DrawGpuSprites(); DrawGpuThemeQuads(); + DrawGpuTexturedTriangles(true); // Blur the world before adding any HUD pixels. Text is Direct2D and // never writes depth, so a depth mask cannot protect it in the post pass. DrawPostProcess(); @@ -2830,6 +2866,7 @@ private: " trianglesDropped=" + std::to_string(m_frameTrianglesDropped) + " texturedTriangles=" + std::to_string(m_frameTexturedTriangles.size()) + " texturedDropped=" + std::to_string(m_frameTexturedTrianglesDropped) + + " decalTriangles=" + std::to_string(m_frameDecalTriangles.size()) + " sprites=" + std::to_string(m_frameSprites.size()) + " spritesDropped=" + std::to_string(m_frameSpritesDropped) + " lines=" + std::to_string(m_frameLines.size()) + " surface=" + @@ -2896,6 +2933,7 @@ private: static constexpr std::size_t kMaxSystemDraws = 128; static constexpr std::size_t kMaxTriangles = 8192; static constexpr std::size_t kMaxTexturedTriangles = 2048; + static constexpr std::size_t kMaxDecalTriangles = 8192; static constexpr std::size_t kMaxSprites = 512; static constexpr std::size_t kMaxThemeQuads = 1024; std::size_t m_frameSystemDrawsDropped = 0; @@ -2909,6 +2947,10 @@ private: std::vector m_frameLines; std::vector m_frameTriangles; std::vector m_frameTexturedTriangles; + std::vector m_frameDecalTriangles; + DecalSurface m_decalSurface; + ComPtr m_decalTexture; + ComPtr m_decalView; std::vector m_frameSprites; std::vector m_frameThemeQuads; std::vector m_frameTexts; diff --git a/xbox/native-bios/Package.appxmanifest b/xbox/native-bios/Package.appxmanifest index 19ca697cfb..e2c999a33a 100644 --- a/xbox/native-bios/Package.appxmanifest +++ b/xbox/native-bios/Package.appxmanifest @@ -6,7 +6,7 @@ IgnorableNamespaces="uap mp rescap"> + Version="1.0.0.53" /> diff --git a/xbox/native-bios/QuickJsEngine.cpp b/xbox/native-bios/QuickJsEngine.cpp index 0d6a081ef5..d2e4e81e4c 100644 --- a/xbox/native-bios/QuickJsEngine.cpp +++ b/xbox/native-bios/QuickJsEngine.cpp @@ -346,6 +346,19 @@ JSValue ThemeReady(JSContext* context, JSValueConst, int, JSValueConst*) { return JS_NewBool(context, scope && scope->api && scope->api->graphics.theme_ready()); } +JSValue DecalClear(JSContext* context, JSValueConst, int, JSValueConst*) { + auto* scope=static_cast(JS_GetContextOpaque(context)); + return JS_NewBool(context,scope&&scope->api&&scope->api->graphics.decal_clear()); +} +JSValue DecalStamp(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { + auto* scope=static_cast(JS_GetContextOpaque(context)); + if(!scope||!scope->api||argc<12)return JS_ThrowTypeError(context,"decalStamp requires 12 coordinates"); + std::array q{}; + for(int i=0;i<12;i++){double n=0;if(JS_ToFloat64(context,&n,argv[i])||!std::isfinite(n)||std::abs(n)>32768)return JS_ThrowRangeError(context,"invalid decal stamp");q[i]=float(n);} + if(q[0]<0||q[1]<0||q[2]<=0||q[3]<=0||q[0]+q[2]>256||q[1]+q[3]>256)return JS_ThrowRangeError(context,"decal stamp outside atlas"); + return JS_NewBool(context,scope->api->graphics.decal_stamp(q)); +} + JSValue ThemeAssetReady(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { auto* scope = static_cast(JS_GetContextOpaque(context)); int32_t asset = -1; @@ -993,6 +1006,9 @@ class QuickJsPiece final : public JsPiece { JS_SetPropertyStr(context_, global, "themeAssetReady", JS_NewCFunction(context_, ThemeAssetReady, "themeAssetReady", 1)); JS_SetPropertyStr(context_, global, "themeSprite", JS_NewCFunction(context_, ThemeSpriteDraw, "themeSprite", 12)); JS_SetPropertyStr(context_, global, "themeQuad", JS_NewCFunction(context_, ThemeQuadDraw, "themeQuad", 17)); + JS_SetPropertyStr(context_, global, "decalClear", JS_NewCFunction(context_, DecalClear, "decalClear", 0)); + JS_SetPropertyStr(context_, global, "decalStamp", JS_NewCFunction(context_, DecalStamp, "decalStamp", 12)); + JS_SetPropertyStr(context_, global, "decalMesh", JS_NewCFunction(context_, DecalMesh, "decalMesh", 6)); JS_SetPropertyStr(context_, global, "systemWrite", JS_NewCFunction(context_, SystemWrite, "systemWrite", 7)); JS_SetPropertyStr(context_, global, "ywftWrite", JS_NewCFunction(context_, YwftWrite, "ywftWrite", 7)); JS_SetPropertyStr(context_, global, "comicWrite", JS_NewCFunction(context_, ComicWrite, "comicWrite", 7)); diff --git a/xbox/native-bios/README.md b/xbox/native-bios/README.md index dc3e131978..5b08d1869a 100644 --- a/xbox/native-bios/README.md +++ b/xbox/native-bios/README.md @@ -28,6 +28,16 @@ pieces never receive a general socket. Revision 25 adds the host-mediated `disc` clock-chat, and painting endpoints; sandboxed pieces do not receive a general HTTP primitive. Runtime failures roll back to the last known good piece. +Revision 53 adds a persistent 2048×2048 RGBA decal surface. `decalClear()` +starts a new surface and reports availability; `decalStamp(sx, sy, sw, sh, +x1, y1, x2, y2, x3, y3, x4, y4)` composites one atlas stamp into texture pixels. +`decalMesh(handle, camera, left, near, width, depth)` projects that texture onto +an existing retained mesh using world X/Z coordinates. Stamps are composited +once, dirty pixels upload only when changed, and frame cost depends on the +fixed mesh rather than elapsed riding time. The CPU canvas and GPU texture +each use 16 MiB. Marks survive rider resets; changing maps or reloading the +piece starts a new surface. + The photo-disc service recursively searches mounted removable volumes for `.jpg`, `.jpeg`, `.jpe`, `.png`, `.tif`, `.tiff`, and `.pcd`. It keeps WinRT `StorageFile` objects and paths inside the native host, bounds discovery to diff --git a/xbox/native-bios/ScenePrimitives.inc b/xbox/native-bios/ScenePrimitives.inc index 42fa982770..09caf0f0ff 100644 --- a/xbox/native-bios/ScenePrimitives.inc +++ b/xbox/native-bios/ScenePrimitives.inc @@ -14,8 +14,8 @@ struct SceneFloats { data=reinterpret_cast(bytes+off);count=len/4;return true; } }; -struct ScenePoint{double x,y,z;}; -static ScenePoint SceneMix(ScenePoint a,ScenePoint 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};} +struct ScenePoint{double x,y,z,u=0,v=0;}; +static ScenePoint SceneMix(ScenePoint a,ScenePoint 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};} template static std::vector SceneClip(const std::vector& in,Distance dist){ std::vector out;out.reserve(in.size()+2); for(size_t i=0;i=0)out.push_back(a);if((da>=0)!=(db>=0))out.push_back(SceneMix(a,b,da/(da-db)));} @@ -97,7 +97,7 @@ static ScenePoint SceneToView(const float* m,const ScenePoint& p){ } static ScenePoint SceneProject(const float* m,const ScenePoint& p){ const double k=m[14]+(m[15]/p.z-m[14])*m[16]; - return {m[12]+p.x*k,m[13]-p.y*k,std::clamp(double(m[22])+p.z*m[23],-1.499,1.4)}; + return {m[12]+p.x*k,m[13]-p.y*k,std::clamp(double(m[22])+p.z*m[23],-1.499,1.4),p.u,p.v}; } template static int SceneClipFixed(const ScenePoint* in,int n,ScenePoint* out,Distance dist){ int count=0; @@ -136,6 +136,40 @@ JSValue MeshFree(JSContext* ctx,JSValueConst,int argc,JSValueConst* args){ if(handle<0||size_t(handle)>=store.size()||!store[handle])return JS_FALSE; store[handle].reset();return JS_TRUE; } +// The retained surface is mapped onto the exact pool mesh, including its +// curved facets. Projection and clipping stay native and independent of age. +JSValue DecalMesh(JSContext* ctx,JSValueConst,int argc,JSValueConst* args){ + auto* scope=static_cast(JS_GetContextOpaque(ctx));int32_t handle=-1;SceneFloats camera(ctx); + if(!scope||!scope->api||argc<6||JS_ToInt32(ctx,&handle,args[0]))return JS_ThrowTypeError(ctx,"decalMesh requires mesh, camera and surface bounds"); + auto& store=scope->scene.meshes; + if(handle<0||size_t(handle)>=store.size()||!store[handle])return JS_NewInt32(ctx,0); + if(!camera.read(args[1])||camera.count!=27||!SceneCameraValid(camera.data))return JS_ThrowRangeError(ctx,"invalid decal camera"); + double bounds[4];for(int i=0;i<4;i++)if(JS_ToFloat64(ctx,&bounds[i],args[i+2])||!std::isfinite(bounds[i])||std::abs(bounds[i])>32768)return JS_ThrowRangeError(ctx,"invalid decal bounds"); + if(bounds[2]<=0||bounds[3]<=0)return JS_ThrowRangeError(ctx,"empty decal surface"); + auto& mesh=*store[handle];const auto* m=camera.data;auto& g=scope->api->graphics; + mesh.view.resize(mesh.verts.size()); + for(size_t i=0;i using namespace ac::xbox; namespace { -class GraphicsProbe final : public Graphics { public: bool themeAvailable = true; int themeSprites = 0, themeQuads = 0; ThemeSprite lastTheme{}; ThemeQuad lastQuad{}; bool theme_ready() const override { return themeAvailable; } bool theme_asset_ready(int asset) const override { return themeAvailable && asset >= 0 && asset < 6; } void theme_sprite(const ThemeSprite& value) override { ++themeSprites; lastTheme = value; } void theme_quad(const ThemeQuad& value) override { ++themeQuads; lastQuad=value; } Color color{}; int boxes = 0; int lines = 0; int triangles = 0; int textured = 0; int sprites = 0; int writes = 0; int systemWrites = 0; int glyphs = 0; int images = 0; int blurs = 0; ImageDraw lastImage{}; void wipe(Color value) override { color = value; } void box(const Rect&) override { ++boxes; } void line(const Line&) override { ++lines; } void triangle(const Triangle&) override { ++triangles; } void textured_triangle(const TexturedTriangle&) override { ++textured; } void sprite(const Sprite&) override { ++sprites; } void write(const Text&) override { ++writes; } void system_write(const SystemText&) override { ++systemWrites; } void system_glyph(const SystemGlyph&) override { ++glyphs; } void image(const ImageDraw& draw) override { ++images; lastImage = draw; } void blur(unsigned) override { ++blurs; } }; +class GraphicsProbe final : public Graphics { public: + DecalSurface surface; int decalTriangles=0; TexturedTriangle lastDecal{}; + bool decal_clear() override { surface.clear();return true; } + bool decal_stamp(const std::array& q) override { return surface.stamp(q); } + void decal_triangle(const TexturedTriangle& t) override { ++decalTriangles;lastDecal=t; } + bool themeAvailable = true; int themeSprites = 0, themeQuads = 0; ThemeSprite lastTheme{}; ThemeQuad lastQuad{}; bool theme_ready() const override { return themeAvailable; } bool theme_asset_ready(int asset) const override { return themeAvailable && asset >= 0 && asset < 6; } void theme_sprite(const ThemeSprite& value) override { ++themeSprites; lastTheme = value; } void theme_quad(const ThemeQuad& value) override { ++themeQuads; lastQuad=value; } Color color{}; int boxes = 0; int lines = 0; int triangles = 0; int textured = 0; int sprites = 0; int writes = 0; int systemWrites = 0; int glyphs = 0; int images = 0; int blurs = 0; ImageDraw lastImage{}; void wipe(Color value) override { color = value; } void box(const Rect&) override { ++boxes; } void line(const Line&) override { ++lines; } void triangle(const Triangle&) override { ++triangles; } void textured_triangle(const TexturedTriangle&) override { ++textured; } void sprite(const Sprite&) override { ++sprites; } void write(const Text&) override { ++writes; } void system_write(const SystemText&) override { ++systemWrites; } void system_glyph(const SystemGlyph&) override { ++glyphs; } void image(const ImageDraw& draw) override { ++images; lastImage = draw; } void blur(unsigned) override { ++blurs; } }; class SoundProbe final : public Sound { public: int calls = 0; int oscillators = 0; int stops = 0; int drums = 0; void synth(const SynthVoice&) override { ++calls; } void stop_all() override {} int sample_rate() const override { return 48000; } void oscillator(float, float) override { ++oscillators; } void oscillator_stop() override { ++stops; } void drum(std::string_view, float, float) override { ++drums; } }; } int main() { @@ -21,6 +27,21 @@ int main() { } assert(transparent > 100 && translucent > 100); } + { + DecalSurface surface;surface.clean(); + const auto* allocation=surface.pixels.data(); + assert(surface.stamp({0,0,128,128,20,20,44,20,44,44,20,44})); + assert(surface.dirty && surface.left==20 && surface.right==44); + unsigned alpha=0;for(unsigned y=20;y<44;y++)for(unsigned x=20;x<44;x++)alpha+=surface.pixels[(y*2048+x)*4+3]; + assert(alpha>0); + const auto before=surface.pixels; + for(int i=0;i<10000;i++)assert(surface.stamp({0,0,128,128,100,100,108,100,108,108,100,108})); + assert(surface.pixels.data()==allocation && surface.pixels.size()==2048u*2048u*4u); + for(unsigned y=20;y<44;y++)for(unsigned x=20;x<44;x++)for(int k=0;k<4;k++) + assert(surface.pixels[(y*2048+x)*4+k]==before[(y*2048+x)*4+k]); + assert(!surface.stamp({250,0,128,128,0,0,10,0,10,10,0,10})); + surface.clear();for(auto value:surface.pixels)assert(value==0); + } for (const uint32_t rate : {44100u, 48000u}) { const auto glass = synthesize_glass(rate); const auto shard = synthesize_glass(rate, true); @@ -156,6 +177,25 @@ int main() { )JS", "test"}, {}, error); assert(decals && error.empty()); decals->paint(api); assert(graphics.lastQuad.asset == 5 && !graphics.lastQuad.depth_write); + auto retainedDecals=engine.compile({"retained-decals","test",R"JS( + function paint(){ + if(!decalClear()||!decalStamp(0,0,128,128,20,20,44,20,44,44,20,44))throw Error('retained stamp'); + let rejected=false;try{decalStamp(250,0,128,128,0,0,1,0,1,1,0,1);}catch(_){rejected=true;} + if(!rejected)throw Error('invalid atlas accepted'); + const vertices=new Float32Array([-10,0,20,10,0,20,10,0,40,-10,0,40]); + const faces=new Float32Array([0,1,2,3,255,255,255,0,-1,0]); + const handle=meshUpload(vertices,faces); + const camera=new Float32Array([0,-10,0, 1,0,0, 0,-1,0, 0,0,1, 100,100,1,100,1,1,0,0,200,200,0,.001,0,-1,0]); + const first=decalMesh(handle,camera,-10,20,20,20); + if(first!==2)throw Error('expected one surface quad'); + for(let i=0;i<1000;i++)decalStamp(0,0,128,128,100,100,108,100,108,108,100,108); + if(decalMesh(handle,camera,-10,20,20,20)!==first)throw Error('draw cost grew'); + meshFree(handle); + } + )JS","test"},{},error); + assert(retainedDecals && error.empty());retainedDecals->paint(api); + assert(graphics.decalTriangles==4); + assert(graphics.lastDecal.u1==0 && graphics.lastDecal.v1==0); graphics.themeAvailable = false; auto unavailable = engine.compile({"missing-theme", "test", R"JS( function paint() { if (themeReady() || themeAssetReady(2) || themeSprite(0,0,0,1,1,0,0,1,1,0,false,0)) throw Error('missing theme must fall back'); } diff --git a/xbox/runtime/include/ac/decal_surface.hpp b/xbox/runtime/include/ac/decal_surface.hpp new file mode 100644 index 0000000000..56ea87f429 --- /dev/null +++ b/xbox/runtime/include/ac/decal_surface.hpp @@ -0,0 +1,54 @@ +#pragma once +#include "decal_atlas.hpp" +#include + +namespace ac::xbox { +// A fixed raster canvas. Stamps are blended once; no stamp history is retained. +class DecalSurface { + public: + static constexpr unsigned side = 2048; + std::vector pixels = std::vector(side * side * 4); + unsigned left=0, top=0, right=side, bottom=side; + bool dirty=true; + void clear() { + std::fill(pixels.begin(),pixels.end(),0); + left=top=0;right=bottom=side;dirty=true; + } + void clean() { dirty=false;left=top=side;right=bottom=0; } + // Source rectangle in the 256px atlas, then four destination pixel points. + bool stamp(const std::array& q) { + for(float v:q)if(!std::isfinite(v)||std::abs(v)>32768)return false; + if(q[0]<0||q[1]<0||q[2]<=0||q[3]<=0||q[0]+q[2]>256||q[1]+q[3]>256)return false; + float loX=q[4],hiX=q[4],loY=q[5],hiY=q[5]; + for(int i=6;i<12;i+=2){loX=(std::min)(loX,q[i]);hiX=(std::max)(hiX,q[i]);loY=(std::min)(loY,q[i+1]);hiY=(std::max)(hiY,q[i+1]);} + const int x0=(std::max)(0,int(std::floor(loX))),x1=(std::min)(int(side),int(std::ceil(hiX))); + const int y0=(std::max)(0,int(std::floor(loY))),y1=(std::min)(int(side),int(std::ceil(hiY))); + if(x0>=x1||y0>=y1)return true; + // One hit per pixel, including the shared diagonal: no double-dark seam. + const auto uv=[&](float x,float y,float& u,float& v){ + for(int half=0;half<2;half++){ + const int b=half?8:6,c=half?10:8; + const float bx=q[b]-q[4],by=q[b+1]-q[5],cx=q[c]-q[4],cy=q[c+1]-q[5],den=bx*cy-by*cx; + if(std::abs(den)<1e-8f)continue; + const float s=((x-q[4])*cy-(y-q[5])*cx)/den,t=(bx*(y-q[5])-by*(x-q[4]))/den; + if(s<0||t<0||s+t>1)continue; + u=half?s:s+t;v=half?s+t:t;return true; + }return false; + }; + for(int y=y0;y atlas=make_decal_atlas(); +}; +} // namespace ac::xbox diff --git a/xbox/runtime/include/ac/runtime.hpp b/xbox/runtime/include/ac/runtime.hpp index aa97d43a68..0ed0a30c9e 100644 --- a/xbox/runtime/include/ac/runtime.hpp +++ b/xbox/runtime/include/ac/runtime.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -103,6 +104,9 @@ class Graphics { virtual bool theme_asset_ready(int asset) const { return asset >= 0 && asset < 2 && theme_ready(); } virtual void theme_sprite(const ThemeSprite&) {} virtual void theme_quad(const ThemeQuad&) {} + virtual bool decal_clear() { return false; } + virtual bool decal_stamp(const std::array&) { return false; } + virtual void decal_triangle(const TexturedTriangle&) {} virtual void write(const Text&) = 0; virtual void system_write(const SystemText&) {} virtual void system_glyph(const SystemGlyph&) {} -- 2.51.2