Initial commit: Western Shooter - Complete implementation

Retro western vertical shooter inspired by Gun.Smoke, built with
TypeScript and WebGL2. Features 3-direction shooting, vertical
scrolling, economy/shop loop, boss fights, and CRT shader effects.

Phases implemented:
- Phase 1: Engine skeleton (WebGL2 renderer, fixed timestep loop, input)
- Phase 2: Shooting identity (3-dir shooting, bullet pools, collision)
- Phase 3: Enemies & patterns (JSON waves, 4 enemy types, parallax bg)
- Phase 4: Economy loop (pickups, shop, upgrades, HUD)
- Phase 5: Boss system (3 bosses, wanted posters, multi-phase attacks)
- Phase 6: Shader layer (CRT effects, bloom, scanlines, screen shake)
- Phase 7: Performance (VAO batching, O(1) allocation, stress testing)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Developer
2026-01-25 19:28:35 -06:00
co-authored by Claude Opus 4.5
commit b070bab2e3
41 changed files with 4891 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
export class Framebuffer {
public readonly framebuffer: WebGLFramebuffer;
public readonly texture: WebGLTexture;
public readonly width: number;
public readonly height: number;
constructor(gl: WebGL2RenderingContext, width: number, height: number) {
this.width = width;
this.height = height;
// Create texture for the framebuffer
const texture = gl.createTexture();
if (!texture) {
throw new Error('Failed to create framebuffer texture');
}
this.texture = texture;
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
width,
height,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
null
);
// Nearest-neighbor filtering for pixel-perfect upscaling
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// Create framebuffer
const framebuffer = gl.createFramebuffer();
if (!framebuffer) {
throw new Error('Failed to create framebuffer');
}
this.framebuffer = framebuffer;
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
texture,
0
);
// Check framebuffer status
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
if (status !== gl.FRAMEBUFFER_COMPLETE) {
throw new Error(`Framebuffer not complete: ${status}`);
}
// Unbind
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.bindTexture(gl.TEXTURE_2D, null);
}
bind(gl: WebGL2RenderingContext): void {
gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer);
gl.viewport(0, 0, this.width, this.height);
}
unbind(gl: WebGL2RenderingContext): void {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
}
+374
View File
@@ -0,0 +1,374 @@
import { INTERNAL_WIDTH, INTERNAL_HEIGHT, ASPECT_RATIO, BACKGROUND_COLOR } from '../constants';
import type { Sprite } from '../types';
import { createWebGL2Context, createShaderProgram } from './webgl-context';
import { Framebuffer } from './framebuffer';
import { SpriteBatch } from './sprite-batch';
const POST_PROCESS_VERT = `
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 POST_PROCESS_FRAG = `
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_screenShake;
uniform float u_damageFlash;
uniform float u_brightness;
varying vec2 v_texCoord;
// CRT curvature amount
const float CURVATURE = 0.03;
// Scanline intensity
const float SCANLINE_INTENSITY = 0.15;
const float SCANLINE_COUNT = 180.0;
// Vignette
const float VIGNETTE_INTENSITY = 0.3;
// Chromatic aberration
const float CHROMA_AMOUNT = 0.002;
// Bloom threshold and intensity
const float BLOOM_THRESHOLD = 0.7;
const float BLOOM_INTENSITY = 0.15;
// Apply barrel distortion for CRT curvature
vec2 curveUV(vec2 uv) {
uv = uv * 2.0 - 1.0;
vec2 offset = abs(uv.yx) / vec2(6.0, 4.0);
uv = uv + uv * offset * offset * CURVATURE;
uv = uv * 0.5 + 0.5;
return uv;
}
// Scanlines
float scanline(vec2 uv) {
float line = sin(uv.y * SCANLINE_COUNT * 3.14159);
return 1.0 - SCANLINE_INTENSITY * (0.5 + 0.5 * line);
}
// Vignette effect
float vignette(vec2 uv) {
uv = uv * 2.0 - 1.0;
float dist = length(uv * vec2(0.8, 1.0));
return 1.0 - smoothstep(0.7, 1.4, dist) * VIGNETTE_INTENSITY;
}
// Simple bloom by sampling bright areas
vec3 bloom(sampler2D tex, vec2 uv, vec2 resolution) {
vec3 sum = vec3(0.0);
float pixelSize = 1.0 / resolution.x;
for (float i = -2.0; i <= 2.0; i += 1.0) {
for (float j = -2.0; j <= 2.0; j += 1.0) {
vec2 offset = vec2(i, j) * pixelSize * 2.0;
vec3 s = texture2D(tex, uv + offset).rgb;
float brightness = max(max(s.r, s.g), s.b);
if (brightness > BLOOM_THRESHOLD) {
sum += s * (brightness - BLOOM_THRESHOLD);
}
}
}
return sum * BLOOM_INTENSITY / 25.0;
}
// Chromatic aberration
vec3 chromaticAberration(sampler2D tex, vec2 uv) {
vec2 dir = uv - 0.5;
float dist = length(dir);
vec2 offset = dir * dist * CHROMA_AMOUNT;
float r = texture2D(tex, uv + offset).r;
float g = texture2D(tex, uv).g;
float b = texture2D(tex, uv - offset).b;
return vec3(r, g, b);
}
// Dithering pattern (Bayer 4x4)
float dither(vec2 position) {
int x = int(mod(position.x, 4.0));
int y = int(mod(position.y, 4.0));
int index = x + y * 4;
float threshold;
if (index == 0) threshold = 0.0;
else if (index == 1) threshold = 8.0;
else if (index == 2) threshold = 2.0;
else if (index == 3) threshold = 10.0;
else if (index == 4) threshold = 12.0;
else if (index == 5) threshold = 4.0;
else if (index == 6) threshold = 14.0;
else if (index == 7) threshold = 6.0;
else if (index == 8) threshold = 3.0;
else if (index == 9) threshold = 11.0;
else if (index == 10) threshold = 1.0;
else if (index == 11) threshold = 9.0;
else if (index == 12) threshold = 15.0;
else if (index == 13) threshold = 7.0;
else if (index == 14) threshold = 13.0;
else threshold = 5.0;
return threshold / 16.0;
}
// Quantize color to limited palette
vec3 quantize(vec3 color, float levels) {
return floor(color * levels + 0.5) / levels;
}
void main() {
vec2 uv = v_texCoord;
// Apply screen shake
if (u_screenShake > 0.0) {
float shake = u_screenShake * 0.01;
uv.x += sin(u_time * 50.0) * shake;
uv.y += cos(u_time * 43.0) * shake;
}
// Apply CRT curvature
vec2 curvedUV = curveUV(uv);
// Check if outside curved screen
if (curvedUV.x < 0.0 || curvedUV.x > 1.0 || curvedUV.y < 0.0 || curvedUV.y > 1.0) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
return;
}
// Sample with chromatic aberration
vec3 color = chromaticAberration(u_texture, curvedUV);
// Add bloom
color += bloom(u_texture, curvedUV, u_resolution);
// Apply scanlines
color *= scanline(curvedUV);
// Apply vignette
color *= vignette(curvedUV);
// Subtle dithering
vec2 pixelPos = curvedUV * u_resolution;
float ditherValue = dither(pixelPos);
color += (ditherValue - 0.5) * 0.03;
// Quantize to limited palette (subtle)
color = mix(color, quantize(color, 32.0), 0.15);
// Apply brightness
color *= u_brightness;
// Apply damage flash
if (u_damageFlash > 0.0) {
color = mix(color, vec3(1.0, 0.2, 0.1), u_damageFlash * 0.4);
}
color = clamp(color, 0.0, 1.0);
gl_FragColor = vec4(color, 1.0);
}
`;
export interface PostProcessParams {
screenShake: number;
damageFlash: number;
brightness: number;
}
export class Renderer {
private canvas: HTMLCanvasElement;
private gl: WebGL2RenderingContext;
private framebuffer: Framebuffer;
private spriteBatch: SpriteBatch;
// Post-process pass
private postProcessProgram: WebGLProgram;
private postProcessVAO: WebGLVertexArrayObject;
private postProcessBuffer: WebGLBuffer;
// Uniform locations
private uResolution: WebGLUniformLocation | null = null;
private uTime: WebGLUniformLocation | null = null;
private uScreenShake: WebGLUniformLocation | null = null;
private uDamageFlash: WebGLUniformLocation | null = null;
private uBrightness: WebGLUniformLocation | null = null;
// Time tracking
private startTime: number;
// Post-process parameters
private params: PostProcessParams = {
screenShake: 0,
damageFlash: 0,
brightness: 1.0,
};
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.gl = createWebGL2Context(canvas);
this.startTime = performance.now();
// Create low-res framebuffer
this.framebuffer = new Framebuffer(this.gl, INTERNAL_WIDTH, INTERNAL_HEIGHT);
// Create sprite batch for world rendering
this.spriteBatch = new SpriteBatch(this.gl);
// Setup post-process pass
this.postProcessProgram = createShaderProgram(this.gl, POST_PROCESS_VERT, POST_PROCESS_FRAG);
// Get uniform locations
this.uResolution = this.gl.getUniformLocation(this.postProcessProgram, 'u_resolution');
this.uTime = this.gl.getUniformLocation(this.postProcessProgram, 'u_time');
this.uScreenShake = this.gl.getUniformLocation(this.postProcessProgram, 'u_screenShake');
this.uDamageFlash = this.gl.getUniformLocation(this.postProcessProgram, 'u_damageFlash');
this.uBrightness = this.gl.getUniformLocation(this.postProcessProgram, 'u_brightness');
const vao = this.gl.createVertexArray();
if (!vao) throw new Error('Failed to create VAO');
this.postProcessVAO = vao;
const buffer = this.gl.createBuffer();
if (!buffer) throw new Error('Failed to create buffer');
this.postProcessBuffer = buffer;
this.setupPostProcessQuad();
this.resize();
// Handle window resize
window.addEventListener('resize', () => this.resize());
}
private setupPostProcessQuad(): void {
const gl = this.gl;
gl.bindVertexArray(this.postProcessVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, this.postProcessBuffer);
// Fullscreen quad with texture coordinates
// Note: v_texCoord.y is flipped for correct orientation
const data = new Float32Array([
// position (x, y), texCoord (u, v)
-1, -1, 0, 0,
1, -1, 1, 0,
-1, 1, 0, 1,
1, -1, 1, 0,
1, 1, 1, 1,
-1, 1, 0, 1,
]);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
const posLoc = gl.getAttribLocation(this.postProcessProgram, 'a_position');
const texLoc = gl.getAttribLocation(this.postProcessProgram, 'a_texCoord');
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0);
gl.enableVertexAttribArray(texLoc);
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 16, 8);
gl.bindVertexArray(null);
}
resize(): void {
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
let width: number;
let height: number;
if (windowWidth / windowHeight > ASPECT_RATIO) {
height = windowHeight;
width = height * ASPECT_RATIO;
} else {
width = windowWidth;
height = width / ASPECT_RATIO;
}
width = Math.floor(width);
height = Math.floor(height);
this.canvas.width = width;
this.canvas.height = height;
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
}
setScreenShake(intensity: number): void {
this.params.screenShake = intensity;
}
setDamageFlash(intensity: number): void {
this.params.damageFlash = intensity;
}
setBrightness(brightness: number): void {
this.params.brightness = brightness;
}
beginFrame(): void {
// Render to low-res framebuffer
this.framebuffer.bind(this.gl);
const { r, g, b, a } = BACKGROUND_COLOR;
this.gl.clearColor(r, g, b, a);
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
// Enable blending for alpha
this.gl.enable(this.gl.BLEND);
this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA);
this.spriteBatch.begin();
}
drawSprite(sprite: Sprite): void {
this.spriteBatch.draw(sprite);
}
endFrame(): void {
// Finish sprite batch
this.spriteBatch.end(INTERNAL_WIDTH, INTERNAL_HEIGHT);
// Unbind framebuffer, render to screen
this.framebuffer.unbind(this.gl);
// Setup viewport for screen
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
// Clear screen
this.gl.clearColor(0, 0, 0, 1);
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
// Draw with post-processing
this.gl.useProgram(this.postProcessProgram);
this.gl.bindVertexArray(this.postProcessVAO);
// Set uniforms
const currentTime = (performance.now() - this.startTime) / 1000;
this.gl.uniform2f(this.uResolution, this.canvas.width, this.canvas.height);
this.gl.uniform1f(this.uTime, currentTime);
this.gl.uniform1f(this.uScreenShake, this.params.screenShake);
this.gl.uniform1f(this.uDamageFlash, this.params.damageFlash);
this.gl.uniform1f(this.uBrightness, this.params.brightness);
this.gl.activeTexture(this.gl.TEXTURE0);
this.gl.bindTexture(this.gl.TEXTURE_2D, this.framebuffer.texture);
this.gl.drawArrays(this.gl.TRIANGLES, 0, 6);
this.gl.bindVertexArray(null);
}
}
+169
View File
@@ -0,0 +1,169 @@
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_screenShake;
uniform float u_damageFlash;
uniform float u_brightness;
varying vec2 v_texCoord;
// CRT curvature amount
const float CURVATURE = 0.03;
// Scanline intensity
const float SCANLINE_INTENSITY = 0.15;
const float SCANLINE_COUNT = 180.0;
// Vignette
const float VIGNETTE_INTENSITY = 0.3;
// Chromatic aberration
const float CHROMA_AMOUNT = 0.002;
// Bloom threshold and intensity
const float BLOOM_THRESHOLD = 0.7;
const float BLOOM_INTENSITY = 0.15;
// Apply barrel distortion for CRT curvature
vec2 curveUV(vec2 uv) {
uv = uv * 2.0 - 1.0;
vec2 offset = abs(uv.yx) / vec2(6.0, 4.0);
uv = uv + uv * offset * offset * CURVATURE;
uv = uv * 0.5 + 0.5;
return uv;
}
// Scanlines
float scanline(vec2 uv) {
float line = sin(uv.y * SCANLINE_COUNT * 3.14159);
return 1.0 - SCANLINE_INTENSITY * (0.5 + 0.5 * line);
}
// Vignette effect
float vignette(vec2 uv) {
uv = uv * 2.0 - 1.0;
float dist = length(uv * vec2(0.8, 1.0));
return 1.0 - smoothstep(0.7, 1.4, dist) * VIGNETTE_INTENSITY;
}
// Simple bloom by sampling bright areas
vec3 bloom(sampler2D tex, vec2 uv, vec2 resolution) {
vec3 sum = vec3(0.0);
float pixelSize = 1.0 / resolution.x;
// Sample in a cross pattern
for (float i = -2.0; i <= 2.0; i += 1.0) {
for (float j = -2.0; j <= 2.0; j += 1.0) {
vec2 offset = vec2(i, j) * pixelSize * 2.0;
vec3 sample = texture2D(tex, uv + offset).rgb;
// Only add bright pixels
float brightness = max(max(sample.r, sample.g), sample.b);
if (brightness > BLOOM_THRESHOLD) {
sum += sample * (brightness - BLOOM_THRESHOLD);
}
}
}
return sum * BLOOM_INTENSITY / 25.0;
}
// Chromatic aberration
vec3 chromaticAberration(sampler2D tex, vec2 uv) {
vec2 dir = uv - 0.5;
float dist = length(dir);
vec2 offset = dir * dist * CHROMA_AMOUNT;
float r = texture2D(tex, uv + offset).r;
float g = texture2D(tex, uv).g;
float b = texture2D(tex, uv - offset).b;
return vec3(r, g, b);
}
// Dithering pattern
float dither(vec2 position) {
int x = int(mod(position.x, 4.0));
int y = int(mod(position.y, 4.0));
int index = x + y * 4;
// Bayer 4x4 dither matrix
float threshold;
if (index == 0) threshold = 0.0;
else if (index == 1) threshold = 8.0;
else if (index == 2) threshold = 2.0;
else if (index == 3) threshold = 10.0;
else if (index == 4) threshold = 12.0;
else if (index == 5) threshold = 4.0;
else if (index == 6) threshold = 14.0;
else if (index == 7) threshold = 6.0;
else if (index == 8) threshold = 3.0;
else if (index == 9) threshold = 11.0;
else if (index == 10) threshold = 1.0;
else if (index == 11) threshold = 9.0;
else if (index == 12) threshold = 15.0;
else if (index == 13) threshold = 7.0;
else if (index == 14) threshold = 13.0;
else threshold = 5.0;
return threshold / 16.0;
}
// Quantize color to limited palette
vec3 quantize(vec3 color, float levels) {
return floor(color * levels + 0.5) / levels;
}
void main() {
// Apply screen shake
vec2 uv = v_texCoord;
if (u_screenShake > 0.0) {
float shake = u_screenShake * 0.01;
uv.x += sin(u_time * 50.0) * shake;
uv.y += cos(u_time * 43.0) * shake;
}
// Apply CRT curvature
vec2 curvedUV = curveUV(uv);
// Check if we're outside the curved screen
if (curvedUV.x < 0.0 || curvedUV.x > 1.0 || curvedUV.y < 0.0 || curvedUV.y > 1.0) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
return;
}
// Sample with chromatic aberration
vec3 color = chromaticAberration(u_texture, curvedUV);
// Add bloom
color += bloom(u_texture, curvedUV, u_resolution);
// Apply scanlines
color *= scanline(curvedUV);
// Apply vignette
color *= vignette(curvedUV);
// Subtle dithering for retro feel
vec2 pixelPos = curvedUV * u_resolution;
float ditherValue = dither(pixelPos);
color += (ditherValue - 0.5) * 0.03;
// Quantize to limited color palette (subtle)
color = mix(color, quantize(color, 32.0), 0.2);
// Apply brightness adjustment
color *= u_brightness;
// Apply damage flash (red tint)
if (u_damageFlash > 0.0) {
color = mix(color, vec3(1.0, 0.2, 0.1), u_damageFlash * 0.4);
}
// Ensure we don't exceed valid range
color = clamp(color, 0.0, 1.0);
gl_FragColor = vec4(color, 1.0);
}
+9
View File
@@ -0,0 +1,9 @@
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;
}
+7
View File
@@ -0,0 +1,7 @@
precision mediump float;
varying vec4 v_color;
void main() {
gl_FragColor = v_color;
}
+22
View File
@@ -0,0 +1,22 @@
attribute vec2 a_position;
attribute vec4 a_color;
uniform vec2 u_resolution;
varying vec4 v_color;
void main() {
// Convert from pixels to 0.0-1.0
vec2 zeroToOne = a_position / u_resolution;
// Convert from 0.0-1.0 to 0.0-2.0
vec2 zeroToTwo = zeroToOne * 2.0;
// Convert from 0.0-2.0 to -1.0-1.0 (clip space)
vec2 clipSpace = zeroToTwo - 1.0;
// Flip Y (screen coords have Y down, clip space has Y up)
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
v_color = a_color;
}
+11
View File
@@ -0,0 +1,11 @@
precision mediump float;
uniform sampler2D u_texture;
varying vec2 v_texCoord;
void main() {
// Simple pass-through for nearest-neighbor upscale
// Nearest-neighbor filtering is set on the texture, not in shader
gl_FragColor = texture2D(u_texture, v_texCoord);
}
+9
View File
@@ -0,0 +1,9 @@
attribute vec2 a_position;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
void main() {
gl_Position = vec4(a_position, 0, 1);
v_texCoord = a_texCoord;
}
+236
View File
@@ -0,0 +1,236 @@
import type { Sprite } from '../types';
import { createShaderProgram } from './webgl-context';
// Inline shaders (will be replaced with imports in production build)
const SPRITE_VERT = `
attribute vec2 a_position;
attribute vec4 a_color;
uniform vec2 u_resolution;
varying vec4 v_color;
void main() {
vec2 zeroToOne = a_position / u_resolution;
vec2 zeroToTwo = zeroToOne * 2.0;
vec2 clipSpace = zeroToTwo - 1.0;
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
v_color = a_color;
}
`;
const SPRITE_FRAG = `
precision mediump float;
varying vec4 v_color;
void main() {
gl_FragColor = v_color;
}
`;
// 4 vertices per quad (indexed), 6 floats per vertex (x, y, r, g, b, a)
const FLOATS_PER_VERTEX = 6;
const VERTICES_PER_QUAD = 4; // Using indexed rendering
const INDICES_PER_QUAD = 6; // 2 triangles
const FLOATS_PER_QUAD = FLOATS_PER_VERTEX * VERTICES_PER_QUAD;
const MAX_QUADS = 10000; // Support for many bullets
export class SpriteBatch {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private vao: WebGLVertexArrayObject;
private vertexBuffer: WebGLBuffer;
private indexBuffer: WebGLBuffer;
private data: Float32Array;
private quadCount = 0;
private resolutionLoc: WebGLUniformLocation;
constructor(gl: WebGL2RenderingContext) {
this.gl = gl;
// Create shader program
this.program = createShaderProgram(gl, SPRITE_VERT, SPRITE_FRAG);
// Get uniform locations
const resLoc = gl.getUniformLocation(this.program, 'u_resolution');
if (!resLoc) {
throw new Error('Failed to get u_resolution location');
}
this.resolutionLoc = resLoc;
// Create VAO
const vao = gl.createVertexArray();
if (!vao) {
throw new Error('Failed to create VAO');
}
this.vao = vao;
// Create vertex buffer
const vertexBuffer = gl.createBuffer();
if (!vertexBuffer) {
throw new Error('Failed to create vertex buffer');
}
this.vertexBuffer = vertexBuffer;
// Create index buffer
const indexBuffer = gl.createBuffer();
if (!indexBuffer) {
throw new Error('Failed to create index buffer');
}
this.indexBuffer = indexBuffer;
// Pre-allocate data array
this.data = new Float32Array(MAX_QUADS * FLOATS_PER_QUAD);
// Setup VAO
this.setupVAO();
// Pre-generate indices
this.generateIndices();
}
private setupVAO(): void {
const gl = this.gl;
gl.bindVertexArray(this.vao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
const positionLoc = gl.getAttribLocation(this.program, 'a_position');
const colorLoc = gl.getAttribLocation(this.program, 'a_color');
// Position attribute
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(
positionLoc,
2,
gl.FLOAT,
false,
FLOATS_PER_VERTEX * 4,
0
);
// Color attribute
gl.enableVertexAttribArray(colorLoc);
gl.vertexAttribPointer(
colorLoc,
4,
gl.FLOAT,
false,
FLOATS_PER_VERTEX * 4,
2 * 4
);
// Bind index buffer to VAO
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
gl.bindVertexArray(null);
}
private generateIndices(): void {
const gl = this.gl;
const indices = new Uint16Array(MAX_QUADS * INDICES_PER_QUAD);
for (let i = 0; i < MAX_QUADS; i++) {
const vertexOffset = i * 4;
const indexOffset = i * 6;
// Triangle 1: top-left, top-right, bottom-left
indices[indexOffset + 0] = vertexOffset + 0;
indices[indexOffset + 1] = vertexOffset + 1;
indices[indexOffset + 2] = vertexOffset + 2;
// Triangle 2: top-right, bottom-right, bottom-left
indices[indexOffset + 3] = vertexOffset + 1;
indices[indexOffset + 4] = vertexOffset + 3;
indices[indexOffset + 5] = vertexOffset + 2;
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
}
begin(): void {
this.quadCount = 0;
}
draw(sprite: Sprite): void {
if (this.quadCount >= MAX_QUADS) {
console.warn('SpriteBatch: max quads exceeded');
return;
}
const { x, y, width, height, color } = sprite;
const { r, g, b, a } = color;
const x1 = x;
const y1 = y;
const x2 = x + width;
const y2 = y + height;
const offset = this.quadCount * FLOATS_PER_QUAD;
// 4 vertices per quad (indexed rendering)
// Top-left (vertex 0)
this.data[offset + 0] = x1;
this.data[offset + 1] = y1;
this.data[offset + 2] = r;
this.data[offset + 3] = g;
this.data[offset + 4] = b;
this.data[offset + 5] = a;
// Top-right (vertex 1)
this.data[offset + 6] = x2;
this.data[offset + 7] = y1;
this.data[offset + 8] = r;
this.data[offset + 9] = g;
this.data[offset + 10] = b;
this.data[offset + 11] = a;
// Bottom-left (vertex 2)
this.data[offset + 12] = x1;
this.data[offset + 13] = y2;
this.data[offset + 14] = r;
this.data[offset + 15] = g;
this.data[offset + 16] = b;
this.data[offset + 17] = a;
// Bottom-right (vertex 3)
this.data[offset + 18] = x2;
this.data[offset + 19] = y2;
this.data[offset + 20] = r;
this.data[offset + 21] = g;
this.data[offset + 22] = b;
this.data[offset + 23] = a;
this.quadCount++;
}
end(resolutionX: number, resolutionY: number): void {
if (this.quadCount === 0) return;
const gl = this.gl;
gl.useProgram(this.program);
gl.uniform2f(this.resolutionLoc, resolutionX, resolutionY);
// Bind VAO (contains all vertex attribute state)
gl.bindVertexArray(this.vao);
// Upload vertex data
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
gl.bufferData(
gl.ARRAY_BUFFER,
this.data.subarray(0, this.quadCount * FLOATS_PER_QUAD),
gl.DYNAMIC_DRAW
);
// Draw with indices
gl.drawElements(gl.TRIANGLES, this.quadCount * INDICES_PER_QUAD, gl.UNSIGNED_SHORT, 0);
gl.bindVertexArray(null);
}
getQuadCount(): number {
return this.quadCount;
}
}
+71
View File
@@ -0,0 +1,71 @@
export function createWebGL2Context(canvas: HTMLCanvasElement): WebGL2RenderingContext {
const gl = canvas.getContext('webgl2', {
alpha: false,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: false,
preserveDrawingBuffer: false,
});
if (!gl) {
throw new Error('WebGL2 is not supported in this browser');
}
return gl;
}
export function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string
): WebGLShader {
const shader = gl.createShader(type);
if (!shader) {
throw new Error('Failed to create shader');
}
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const info = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`Shader compilation failed: ${info}`);
}
return shader;
}
export function createProgram(
gl: WebGL2RenderingContext,
vertexShader: WebGLShader,
fragmentShader: WebGLShader
): WebGLProgram {
const program = gl.createProgram();
if (!program) {
throw new Error('Failed to create program');
}
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const info = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error(`Program linking failed: ${info}`);
}
return program;
}
export function createShaderProgram(
gl: WebGL2RenderingContext,
vertexSource: string,
fragmentSource: string
): WebGLProgram {
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource);
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
return createProgram(gl, vertexShader, fragmentShader);
}