mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-31 10:18:57 +00:00
239 lines
6.6 KiB
TypeScript
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)
|
|
},
|
|
}
|
|
}
|