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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
precision mediump float;
|
||||
|
||||
varying vec4 v_color;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = v_color;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user