Files
The412Banner c5d7cdc058 Xbox Cloud Gaming: stream from the store and Games tab, native player with DroidDeck frame generation (#1531)
Xbox Cloud Gaming: stream from the store and Games tab, native player with DroidDeck frame generation

Stream Xbox games with Xbox Cloud Gaming (Game Pass) from DroidDeck, outside
the Linux session.

- Backend: `xodus-cli cloud-token` (tools/xbox/droiddeck-cloud-token.patch)
  gets the streaming tokens; they stay in memory.
- Web player (default): the Greenlight player page (tools/xcloud-player, MIT)
  in a WebView, plus Microsoft's own xbox.com/play player. In-stream menu with
  screen effects (sharpen, colour, deband, FXAA, CRT, NTSC, Toon, fps cap),
  stream quality, performance overlay (level, layout, top right / top left)
  and rumble.
- Native player (opt-in, Android 10+, Player engine: Native): Google's WebRTC
  (io.github.webrtc-sdk 125.6422.07, BSD) with the Greenlight protocol ported
  to Kotlin, in its own process (:xcloudnative) with its own instance of
  DroidDeck's compositor. Decoded frames reach the compositor as
  AHardwareBuffers (new stream mode, VK_ANDROID_external_memory_android_
  hardware_buffer import), so DDFG 2x and LSFG 2x/3x/4x run on them. Live
  frame generation, frame-rate target (presets and a 10-165 fps slider, even
  pacing), zero-copy picture, small network buffer, fast decoder, instant
  controller input, overlay with engine and on-screen fps, background end,
  restart and web fallback. It takes the main process's tokens through
  XboxCloudTokenService; they are fetched ahead while a cloud game is shown.
- The compositor requests the dma-buf import extensions only where the driver
  has them (the system driver has none).
- Catalog: cloud availability, Game Pass plan CFQ7TTC0K6L8 counted, library
  filter All / Owned / Game Pass / Cloud, Stream and Add to Games on the store
  page, stream-only Games-tab entries (Stream + Download / Play + Stream).
- Xbox games get VC++ 2015-2022 and the ARM64 runtime registry entries
  (vcredist2022_arm64, built in until the catalog has it) before each launch.
- Controller focus reaches pickers opened from a game page (install location,
  uninstall); Add / Remove from Games keep the focus.

Credits: Greenlight by UnknownSKL (Jim Kroon), MIT, player and protocol;
Better xCloud for the resolution / OS mapping; MGamer404's Bannerlator #627
for the ARM64 runtime approach. Ported from the412banner's Bannerlator.
2026-10-24 23:17:57 -04:00

483 lines
20 KiB
TypeScript

import xCloudPlayer from '../player'
import Overlay from './overlay'
export default class WebGpuComponent {
private _player:xCloudPlayer
private _element:HTMLCanvasElement | undefined
private _overlay:Overlay
private _isActive: boolean = true
private _gpuReader:GPUCanvasContext | undefined
private _gpuVideoTexture:GPUTexture | null = null
private _gpuDevice:GPUDevice | null = null
private _gpuContext:GPUCanvasContext | null = null
private _gpuBindGroup:GPUBindGroup | null = null
private _gpuPipeline:GPURenderPipeline | null = null
private _gpuSampler:GPUSampler | null = null
private _resizeObserver:ResizeObserver | null = null
private _containerElement:HTMLElement | null = null
private _isProcessingFrame: boolean = false
private _frameQueueSize: number = 0
private _droppedFrames: number = 0
private _lastFrameTime: number = 0
private _renderingFps: number = 0
private _frameTimeSamples: number[] = []
private _renderDelayMs: number = 0
private _renderDelaySamples: number[] = []
constructor(player:any){
this._player = player
this._overlay = new Overlay(this, this._player)
}
create(stream:MediaStream) {
const videoElement = document.createElement('canvas')
console.log('Creating WebGPU canvas element for stream:', stream);
// videoElement.srcObject = stream
// videoElement.autoplay = true
// videoElement.muted = true
// videoElement.playsInline = true
videoElement.width = 1920
videoElement.height = 1080
videoElement.style.willChange = 'contents'
// videoElement.style.imageRendering = 'pixelated' // or 'crisp-edges' for faster rendering
// videoElement.style.objectFit = 'contain'
// videoElement.style.backgroundColor = 'black'
// videoElement.style.touchAction = 'none'
const that:WebGpuComponent = this
navigator.gpu.requestAdapter().then((adapter:any) => {
if (!adapter) {
console.error('Failed to get GPU adapter');
return;
}
console.log('WebGPU adapter:', adapter);
adapter.requestDevice().then((device:any) => {
const context = videoElement.getContext("webgpu");
if (!context) {
console.error('Failed to get WebGPU context');
return;
}
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device,
format,
alphaMode: 'opaque',
// Enable desynchronized mode for lower latency and reduced jitter
usage: GPUTextureUsage.RENDER_ATTACHMENT
});
this._gpuDevice = device
this._gpuContext = context
// Additional WebGPU setup and rendering logic would go here
that._gpuVideoTexture = this._gpuDevice.createTexture({
size: [videoElement.width, videoElement.height],
format: "rgba8unorm",
usage: GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT
});
const vertexModule = this._gpuDevice.createShaderModule({
code: `
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput {
var pos = array<vec2<f32>, 4>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, -1.0),
vec2<f32>(-1.0, 1.0),
vec2<f32>( 1.0, 1.0)
);
var uv = array<vec2<f32>, 4>(
vec2<f32>(0.0, 1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 0.0)
);
var output: VertexOutput;
output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);
output.uv = uv[VertexIndex];
return output;
}
`
});
const fragmentModule = this._gpuDevice.createShaderModule({
code: `
@group(0) @binding(0) var myTexture: texture_2d<f32>;
@group(0) @binding(1) var mySampler: sampler;
@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
// Get texture dimensions
let texSize = vec2<f32>(textureDimensions(myTexture));
let invTexSize = 1.0 / texSize;
// Edge-aware upscaling with Catmull-Rom interpolation
// Sample 3x3 neighborhood for edge detection
let p00 = textureSample(myTexture, mySampler, uv + vec2<f32>(-1.0, -1.0) * invTexSize).rgb;
let p10 = textureSample(myTexture, mySampler, uv + vec2<f32>( 0.0, -1.0) * invTexSize).rgb;
let p20 = textureSample(myTexture, mySampler, uv + vec2<f32>( 1.0, -1.0) * invTexSize).rgb;
let p01 = textureSample(myTexture, mySampler, uv + vec2<f32>(-1.0, 0.0) * invTexSize).rgb;
let p11 = textureSample(myTexture, mySampler, uv).rgb;
let p21 = textureSample(myTexture, mySampler, uv + vec2<f32>( 1.0, 0.0) * invTexSize).rgb;
let p02 = textureSample(myTexture, mySampler, uv + vec2<f32>(-1.0, 1.0) * invTexSize).rgb;
let p12 = textureSample(myTexture, mySampler, uv + vec2<f32>( 0.0, 1.0) * invTexSize).rgb;
let p22 = textureSample(myTexture, mySampler, uv + vec2<f32>( 1.0, 1.0) * invTexSize).rgb;
// Calculate Sobel edge detection
let edgeX = -p00 + p20 - 2.0*p01 + 2.0*p21 - p02 + p22;
let edgeY = p00 + 2.0*p10 + p20 - p02 - 2.0*p12 - p22;
let edge = length(edgeX) + length(edgeY);
// Sample with adaptive filtering based on edge strength
let centerSample = textureSample(myTexture, mySampler, uv);
var resultRgb = centerSample.rgb;
// For smooth areas, apply slight smoothing
// For edge areas, keep sharp sampling
if (edge < 0.1) {
// Smooth region: blend neighboring pixels for smoothness
let neighbors = (p10 + p01 + p21 + p12) * 0.25;
resultRgb = mix(resultRgb, neighbors, 0.15);
} else {
// Edge region: apply subtle sharpening to maintain clarity
resultRgb = resultRgb * 1.1 - (p10 + p01 + p21 + p12) * 0.025;
}
return vec4<f32>(resultRgb, centerSample.a);
}
`
});
// Render pipeline (vertex + fragment shader)
this._gpuPipeline = this._gpuDevice.createRenderPipeline({
vertex: {
module: vertexModule,
entryPoint: "main"
},
fragment: {
module: fragmentModule,
entryPoint: "main",
targets: [{ format: navigator.gpu.getPreferredCanvasFormat() }]
},
primitive: { topology: "triangle-strip" },
layout: "auto",
});
// Create sampler once and reuse it
this._gpuSampler = this._gpuDevice.createSampler({
magFilter: "linear",
minFilter: "linear",
addressModeU: "clamp-to-edge",
addressModeV: "clamp-to-edge",
});
// Process video frames from the MediaStream
// console.log('Stream tracks:', stream);
const track = stream.getVideoTracks()[0];
const processor = new MediaStreamTrackProcessor({ track });
this._gpuReader = processor.readable.getReader();
this._gpuBindGroup = this._gpuDevice.createBindGroup({
layout: this._gpuPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: this._gpuVideoTexture.createView() },
{ binding: 1, resource: this._gpuSampler },
]
});
// Start rendering loop
that.processVideoData();
});
});
// console.log('WebGPU adapter:', adapter, navigator.gpu);
// const device = adapter.requestDevice();
// console.log('Device:', device);
// const context = videoElement.getContext("webgpu");
// console.log('Context:', context);
// const format = navigator.gpu.getPreferredCanvasFormat();
// context.configure({ device, format });
// Rest
const element = document.getElementById(this._player.getElementId())
if(element === null) {return}
this._element = videoElement
this._containerElement = element
element.appendChild(this._element)
const isStatic = getComputedStyle(element).position === 'static' ? true : false
if(isStatic === true){
element.style.position = 'relative'
}
// Make canvas fill the container
this._element.style.width = '100%'
this._element.style.height = '100%'
this._element.style.display = 'block'
// Set up resize observer to handle canvas resizing
this._resizeObserver = new ResizeObserver(() => {
this.resizeCanvas()
})
this._resizeObserver.observe(element)
// Initial resize
this.resizeCanvas()
// this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this))
}
async processVideoData(timestamp?:number) {
if (this._isProcessingFrame || !this._isActive) {
return;
}
this._isProcessingFrame = true;
try {
// Non-blocking read - check if frame is available without waiting
const { value: frame, done } = await this._gpuReader.read();
if (done || !this._isActive || !frame) {
this._isProcessingFrame = false;
return;
}
// Frame dropping: if multiple frames queued, skip older ones to maintain order and latency
// This prevents frames from being processed out of order
if (this._frameQueueSize > 0) {
frame.close();
this._droppedFrames++;
this._isProcessingFrame = false;
// Continue processing next frame immediately without delay
this.videoLoop();
return;
}
this._frameQueueSize++;
// Resize texture if video frame dimensions changed
if (this._gpuVideoTexture &&
(this._gpuVideoTexture.width !== frame.codedWidth ||
this._gpuVideoTexture.height !== frame.codedHeight)) {
this._gpuVideoTexture.destroy();
this._gpuVideoTexture = this._gpuDevice.createTexture({
size: [frame.codedWidth, frame.codedHeight],
format: "rgba8unorm",
usage: GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT
});
// Recreate bind group with new texture
this._gpuBindGroup = this._gpuDevice.createBindGroup({
layout: this._gpuPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: this._gpuVideoTexture.createView() },
{ binding: 1, resource: this._gpuSampler },
]
});
console.log(`Video texture resized to ${frame.codedWidth}x${frame.codedHeight}`);
}
const renderStartTime = performance.now();
// Track frame timing for FPS calculation
if (this._lastFrameTime > 0) {
const frameTime = renderStartTime - this._lastFrameTime;
this._frameTimeSamples.push(frameTime);
// Keep only last 60 samples for smooth FPS average
if (this._frameTimeSamples.length > 60) {
this._frameTimeSamples.shift();
}
// Update FPS based on average frame time
const avgFrameTime = this._frameTimeSamples.reduce((a, b) => a + b, 0) / this._frameTimeSamples.length;
this._renderingFps = Math.round(1000 / avgFrameTime);
}
this._lastFrameTime = renderStartTime;
// Upload the video frame to GPU
this._gpuDevice.queue.copyExternalImageToTexture(
{ source: frame },
{ texture: this._gpuVideoTexture },
[frame.codedWidth, frame.codedHeight]
);
// Render to canvas
const commandEncoder = this._gpuDevice.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [{
view: this._gpuContext.getCurrentTexture().createView(),
loadOp: 'clear',
storeOp: 'store',
clearValue: { r: 0, g: 0, b: 0, a: 1 },
}],
});
passEncoder.setPipeline(this._gpuPipeline);
passEncoder.setBindGroup(0, this._gpuBindGroup);
passEncoder.draw(4);
passEncoder.end();
this._gpuDevice.queue.submit([commandEncoder.finish()]);
const renderEndTime = performance.now();
const renderDelay = renderEndTime - renderStartTime;
// Track render delay (time to process and submit frame to GPU)
this._renderDelaySamples.push(renderDelay);
// Keep only last 60 samples for smooth average
if (this._renderDelaySamples.length > 60) {
this._renderDelaySamples.shift();
}
// Update average render delay
this._renderDelayMs = Math.round(
this._renderDelaySamples.reduce((a, b) => a + b, 0) / this._renderDelaySamples.length * 100
) / 100; // Round to 2 decimal places
// Queue metadata frames with actual render timing
this._player._channels.input.queueMetadataFrame({
serverDataKey: frame.timestamp as number,
firstFramePacketArrivalTimeMs: frame.timestamp as number,
frameSubmittedTimeMs: renderStartTime,
frameDecodedTimeMs: renderStartTime,
frameRenderedTimeMs: renderEndTime,
})
frame.close();
this._frameQueueSize--;
} catch (error) {
console.error('Error processing video frame:', error);
} finally {
this._isProcessingFrame = false;
// Continue processing next frame immediately without vsync delay for low-latency streaming
if (this._isActive) {
this.videoLoop();
}
}
}
videoLoop() {
this.processVideoData();
}
resizeCanvas() {
if (!this._element || !this._containerElement || !this._gpuContext || !this._gpuDevice) {
return
}
// Get the display size (CSS pixels)
const displayWidth = this._containerElement.clientWidth
const displayHeight = this._containerElement.clientHeight
// Get device pixel ratio for sharp rendering on high-DPI displays
const devicePixelRatio = window.devicePixelRatio || 1
// Calculate the actual canvas size in device pixels
const width = Math.floor(displayWidth * devicePixelRatio)
const height = Math.floor(displayHeight * devicePixelRatio)
// Check if canvas needs resizing
if (this._element.width !== width || this._element.height !== height) {
this._element.width = width
this._element.height = height
// Reconfigure the WebGPU context with the new size
const format = navigator.gpu.getPreferredCanvasFormat()
this._gpuContext.configure({
device: this._gpuDevice,
format,
alphaMode: 'opaque',
usage: GPUTextureUsage.RENDER_ATTACHMENT
})
console.log(`Canvas resized to ${width}x${height} (display: ${displayWidth}x${displayHeight}, DPR: ${devicePixelRatio})`)
}
}
// processVideoMetadata(timestamp, data:VideoFrameCallbackMetadata) {
// if(this._element === undefined) {return}
// this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this))
// this._player._channels.input.queueMetadataFrame({
// serverDataKey: data.rtpTimestamp as number,
// firstFramePacketArrivalTimeMs: data.receiveTime as number,
// frameSubmittedTimeMs: data.receiveTime as number,
// frameDecodedTimeMs: data.expectedDisplayTime,
// frameRenderedTimeMs: data.expectedDisplayTime,
// })
// }
getElement(){
return this._element
}
toggleDebugOverlay(){
this._overlay.toggleDebug()
}
getFrameStats() {
return {
droppedFrames: this._droppedFrames,
queueSize: this._frameQueueSize,
isProcessing: this._isProcessingFrame,
renderingFps: this._renderingFps,
renderDelayMs: this._renderDelayMs
}
}
destroy(){
const streamHolder = document.getElementById(this._player.getElementId())
this._isActive = false
const element = streamHolder?.querySelector('canvas')
// Clean up GPU reader
if(this._gpuReader){
this._gpuReader.cancel();
this._gpuReader = undefined;
}
if(this._resizeObserver){
this._resizeObserver.disconnect()
this._resizeObserver = null
}
if(this._gpuVideoTexture){
this._gpuVideoTexture.destroy();
this._gpuVideoTexture = null;
}
if(this._overlay !== undefined){
this._overlay.destroy()
}
if(element){
element.remove()
}
if(this._element){
this._element.remove()
}
}
}