Files
sky_phone/frontend/src/utils/gameView.ts
T
2026-08-06 16:15:05 +02:00

239 lines
6.6 KiB
TypeScript

const VERTEX_SHADER = `
attribute vec2 a_position;
attribute vec2 a_texcoord;
varying vec2 v_texcoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texcoord = a_texcoord;
}
`
const FRAGMENT_SHADER = `
varying highp vec2 v_texcoord;
uniform sampler2D u_texture;
void main() {
gl_FragColor = texture2D(u_texture, v_texcoord);
}
`
export interface GameView {
readonly canvas: HTMLCanvasElement
dispose(): void
isLost(): boolean
render(): void
resize(
width: number,
height: number,
sourceWidth?: number,
sourceHeight?: number,
zoom?: number,
): void
}
export interface GameViewOptions {
preserveDrawingBuffer?: boolean
}
export function gameViewGeometry(
sourceWidth: number,
sourceHeight: number,
targetWidth: number,
targetHeight: number,
zoom = 1,
): { positions: Float32Array; textureCoordinates: Float32Array } {
const sourceAspect = sourceWidth / sourceHeight
const targetAspect = targetWidth / targetHeight
let left = 0
let right = 1
let top = 0
let bottom = 1
if (sourceAspect > targetAspect) {
const visibleWidth = targetAspect / sourceAspect
left = (1 - visibleWidth) / 2
right = 1 - left
} else if (sourceAspect < targetAspect) {
const visibleHeight = sourceAspect / targetAspect
top = (1 - visibleHeight) / 2
bottom = 1 - top
}
const normalizedZoom = Math.min(3, Math.max(1, zoom))
const centerX = (left + right) / 2
const centerY = (top + bottom) / 2
left = Math.max(0, centerX + (left - centerX) / normalizedZoom)
right = Math.min(1, centerX + (right - centerX) / normalizedZoom)
top = Math.max(0, centerY + (top - centerY) / normalizedZoom)
bottom = Math.min(1, centerY + (bottom - centerY) / normalizedZoom)
return {
positions: new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
textureCoordinates: new Float32Array([
left,
top,
right,
top,
left,
bottom,
right,
bottom,
]),
}
}
function compileShader(
gl: WebGLRenderingContext,
type: number,
source: string,
): WebGLShader {
const shader = gl.createShader(type)
if (!shader) throw new Error('game_view_shader_unavailable')
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error(gl.getShaderInfoLog(shader) || 'game_view_shader_failed')
}
return shader
}
export function createGameView(
canvas: HTMLCanvasElement,
options: GameViewOptions = {},
): GameView {
const gl = canvas.getContext('webgl', {
alpha: false,
antialias: false,
depth: false,
desynchronized: true,
failIfMajorPerformanceCaveat: false,
preserveDrawingBuffer: options.preserveDrawingBuffer === true,
stencil: false,
}) as WebGLRenderingContext | null
if (!gl) throw new Error('game_view_unavailable')
let lost = false
let disposed = false
const onContextLost = (event: Event) => {
event.preventDefault()
lost = true
console.error('[Camera] Game-view WebGL context lost.')
}
canvas.addEventListener(
'webglcontextlost',
onContextLost as EventListener,
false,
)
const program = gl.createProgram()
if (!program) throw new Error('game_view_program_unavailable')
gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER))
gl.attachShader(
program,
compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER),
)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(program) || 'game_view_program_failed')
}
gl.useProgram(program)
const positionLocation = gl.getAttribLocation(program, 'a_position')
const texcoordLocation = gl.getAttribLocation(program, 'a_texcoord')
if (positionLocation < 0 || texcoordLocation < 0) {
throw new Error('game_view_attributes_unavailable')
}
const positionBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.DYNAMIC_DRAW,
)
gl.enableVertexAttribArray(positionLocation)
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
const texcoordBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]),
gl.STATIC_DRAW,
)
gl.enableVertexAttribArray(texcoordLocation)
gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0)
const texture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 255]),
)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
// CitizenFX watches this exact wrap-mode sequence and replaces the seeded pixel with the live
// game backbuffer. These calls are intentionally not redundant.
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0)
gl.clearColor(0, 0, 0, 1)
return {
canvas,
dispose() {
if (disposed) return
disposed = true
canvas.removeEventListener(
'webglcontextlost',
onContextLost as EventListener,
false,
)
gl.getExtension('WEBGL_lose_context')?.loseContext()
},
isLost: () => lost,
render() {
if (disposed || lost) return
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
gl.finish()
},
resize(
width: number,
height: number,
sourceWidth = window.innerWidth,
sourceHeight = window.innerHeight,
zoom = 1,
) {
if (disposed || lost) return
canvas.width = width
canvas.height = height
const geometry = gameViewGeometry(
sourceWidth,
sourceHeight,
width,
height,
zoom,
)
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.DYNAMIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
geometry.textureCoordinates,
gl.DYNAMIC_DRAW,
)
gl.viewport(0, 0, width, height)
},
}
}