forked from Keelrleek/SoulStream
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
544 lines
20 KiB
JavaScript
544 lines
20 KiB
JavaScript
export const HERO_MASTER_WIDTH = 3840;
|
|
export const HERO_MASTER_HEIGHT = 2160;
|
|
|
|
const HEADER_BYTES = 16;
|
|
const MAGIC = [0x53, 0x48, 0x47, 0x31]; // SHG1
|
|
|
|
const VS = `#version 300 es
|
|
precision highp float;
|
|
const vec2 pos[3] = vec2[3](vec2(-1.,-1.), vec2(3.,-1.), vec2(-1.,3.));
|
|
void main(){ gl_Position = vec4(pos[gl_VertexID], 0., 1.); }`;
|
|
|
|
// FidelityFX FSR 1 EASU, transliterated from AMD's reference implementation.
|
|
// `flipInput` is used only for the first pass because Rust's RGBA rows arrive
|
|
// top-first while render-target textures use conventional GL bottom-first rows.
|
|
const FS_EASU = `#version 300 es
|
|
precision highp float;
|
|
uniform sampler2D src;
|
|
uniform vec2 inSize;
|
|
uniform vec2 outSize;
|
|
uniform float flipInput;
|
|
out vec4 frag;
|
|
float lumaOf(vec3 c){ return c.b*0.5 + (c.r*0.5 + c.g); }
|
|
vec3 tex(ivec2 p){ return texelFetch(src, clamp(p, ivec2(0), ivec2(inSize)-1), 0).rgb; }
|
|
void easuSet(inout vec2 dir, inout float len, vec2 pp, bool biS, bool biT, bool biU, bool biV,
|
|
float lA, float lB, float lC, float lD, float lE){
|
|
float w = 0.0;
|
|
if (biS) w = (1.0-pp.x)*(1.0-pp.y);
|
|
if (biT) w = pp.x*(1.0-pp.y);
|
|
if (biU) w = (1.0-pp.x)*pp.y;
|
|
if (biV) w = pp.x*pp.y;
|
|
float dc = lD-lC, cb = lC-lB;
|
|
float lenX = max(abs(dc), abs(cb));
|
|
lenX = 1.0/max(lenX, 1e-5);
|
|
float dirX = lD-lB;
|
|
dir.x += dirX*w;
|
|
lenX = clamp(abs(dirX)*lenX, 0.0, 1.0);
|
|
lenX *= lenX;
|
|
len += lenX*w;
|
|
float ec = lE-lC, ca = lC-lA;
|
|
float lenY = max(abs(ec), abs(ca));
|
|
lenY = 1.0/max(lenY, 1e-5);
|
|
float dirY = lE-lA;
|
|
dir.y += dirY*w;
|
|
lenY = clamp(abs(dirY)*lenY, 0.0, 1.0);
|
|
lenY *= lenY;
|
|
len += lenY*w;
|
|
}
|
|
void easuTap(inout vec3 aC, inout float aW, vec2 off, vec2 dir, vec2 len, float lob, float clp, vec3 c){
|
|
vec2 v = vec2(off.x*dir.x + off.y*dir.y, off.x*(-dir.y) + off.y*dir.x);
|
|
v *= len;
|
|
float d2 = min(v.x*v.x + v.y*v.y, clp);
|
|
float wB = 0.4*d2 - 1.0;
|
|
float wA = lob*d2 - 1.0;
|
|
wB *= wB; wA *= wA;
|
|
wB = (25.0/16.0)*wB - (25.0/16.0 - 1.0);
|
|
float w = wB*wA;
|
|
aC += c*w; aW += w;
|
|
}
|
|
void main(){
|
|
vec2 ip = floor(gl_FragCoord.xy);
|
|
if (flipInput > 0.5) ip.y = outSize.y - 1.0 - ip.y;
|
|
vec2 pp = (ip + 0.5)*(inSize/outSize) - 0.5;
|
|
vec2 fp = floor(pp);
|
|
pp -= fp;
|
|
ivec2 F = ivec2(fp);
|
|
vec3 bC = tex(F+ivec2(0,-1)), cC = tex(F+ivec2(1,-1));
|
|
vec3 eC = tex(F+ivec2(-1,0)), fC = tex(F), gC = tex(F+ivec2(1,0)), hC = tex(F+ivec2(2,0));
|
|
vec3 iC = tex(F+ivec2(-1,1)), jC = tex(F+ivec2(0,1)), kC = tex(F+ivec2(1,1)), lC2 = tex(F+ivec2(2,1));
|
|
vec3 nC = tex(F+ivec2(0,2)), oC = tex(F+ivec2(1,2));
|
|
float bL = lumaOf(bC), cL = lumaOf(cC), eL = lumaOf(eC), fL = lumaOf(fC);
|
|
float gL = lumaOf(gC), hL = lumaOf(hC), iL = lumaOf(iC), jL = lumaOf(jC);
|
|
float kL = lumaOf(kC), lL = lumaOf(lC2), nL = lumaOf(nC), oL = lumaOf(oC);
|
|
vec2 dir = vec2(0.0); float len = 0.0;
|
|
easuSet(dir, len, pp, true,false,false,false, bL, eL, fL, gL, jL);
|
|
easuSet(dir, len, pp, false,true,false,false, cL, fL, gL, hL, kL);
|
|
easuSet(dir, len, pp, false,false,true,false, fL, iL, jL, kL, nL);
|
|
easuSet(dir, len, pp, false,false,false,true, gL, jL, kL, lL, oL);
|
|
vec2 dir2 = dir*dir;
|
|
float dirR = dir2.x + dir2.y;
|
|
bool zro = dirR < (1.0/32768.0);
|
|
dirR = inversesqrt(max(dirR, 1e-12));
|
|
dirR = zro ? 1.0 : dirR;
|
|
dir.x = zro ? 1.0 : dir.x;
|
|
dir *= dirR;
|
|
len = len*0.5;
|
|
len *= len;
|
|
float stretch = (dir.x*dir.x + dir.y*dir.y)/max(max(abs(dir.x), abs(dir.y)), 1e-5);
|
|
vec2 len2 = vec2(1.0 + (stretch - 1.0)*len, 1.0 - 0.5*len);
|
|
float lob = 0.5 + ((1.0/4.0 - 0.04) - 0.5)*len;
|
|
float clp = 1.0/max(lob, 1e-5);
|
|
vec3 min4 = min(min(fC, gC), min(jC, kC));
|
|
vec3 max4 = max(max(fC, gC), max(jC, kC));
|
|
vec3 aC = vec3(0.0); float aW = 0.0;
|
|
easuTap(aC, aW, vec2( 0.,-1.)-pp, dir, len2, lob, clp, bC);
|
|
easuTap(aC, aW, vec2( 1.,-1.)-pp, dir, len2, lob, clp, cC);
|
|
easuTap(aC, aW, vec2(-1., 1.)-pp, dir, len2, lob, clp, iC);
|
|
easuTap(aC, aW, vec2( 0., 1.)-pp, dir, len2, lob, clp, jC);
|
|
easuTap(aC, aW, vec2( 0., 0.)-pp, dir, len2, lob, clp, fC);
|
|
easuTap(aC, aW, vec2(-1., 0.)-pp, dir, len2, lob, clp, eC);
|
|
easuTap(aC, aW, vec2( 1., 1.)-pp, dir, len2, lob, clp, kC);
|
|
easuTap(aC, aW, vec2( 1., 0.)-pp, dir, len2, lob, clp, gC);
|
|
easuTap(aC, aW, vec2( 0., 2.)-pp, dir, len2, lob, clp, nC);
|
|
easuTap(aC, aW, vec2( 1., 2.)-pp, dir, len2, lob, clp, oC);
|
|
easuTap(aC, aW, vec2( 2., 1.)-pp, dir, len2, lob, clp, lC2);
|
|
easuTap(aC, aW, vec2( 2., 0.)-pp, dir, len2, lob, clp, hC);
|
|
frag = vec4(min(max4, max(min4, aC/aW)), 1.0);
|
|
}`;
|
|
|
|
const FS_RCAS = `#version 300 es
|
|
precision highp float;
|
|
uniform sampler2D src;
|
|
uniform vec2 size;
|
|
uniform float con;
|
|
out vec4 frag;
|
|
vec3 tex(ivec2 p){ return texelFetch(src, clamp(p, ivec2(0), ivec2(size)-1), 0).rgb; }
|
|
void main(){
|
|
ivec2 sp = ivec2(gl_FragCoord.xy);
|
|
vec3 b = tex(sp+ivec2(0,-1)), d = tex(sp+ivec2(-1,0)), e = tex(sp), f = tex(sp+ivec2(1,0)), h = tex(sp+ivec2(0,1));
|
|
vec3 mn4 = min(min(b,d),min(f,h)), mx4 = max(max(b,d),max(f,h));
|
|
vec3 hitMin = min(mn4,e)/(4.0*mx4+1e-5);
|
|
vec3 hitMax = (1.0-max(mx4,e))/(4.0*mn4-4.0-1e-5);
|
|
vec3 lobeRGB = max(-hitMin,hitMax);
|
|
float lobe = max(-0.1875, min(max(lobeRGB.r,max(lobeRGB.g,lobeRGB.b)),0.0))*con;
|
|
frag = vec4(((b+d+f+h)*lobe+e)/(4.0*lobe+1.0), 1.0);
|
|
}`;
|
|
|
|
// Exact shipped Ruby Chrome 30% + vintage fade 100% + native-4K grain 70%.
|
|
// `trunc`, not `floor`, matches Rust's signed integer division toward zero.
|
|
const FS_GRADE = `#version 300 es
|
|
precision highp float;
|
|
uniform sampler2D src;
|
|
uniform float grainAmp;
|
|
uniform float seed;
|
|
out vec4 frag;
|
|
float ss(float e0,float e1,float v){ float t=clamp((v-e0)/(e1-e0),0.,1.); return t*t*(3.-2.*t); }
|
|
uint grainHash(uint x){
|
|
x ^= x >> 16; x *= 0x7feb352du;
|
|
x ^= x >> 15; x *= 0x846ca68bu;
|
|
return x ^ (x >> 16);
|
|
}
|
|
float grainByte(ivec2 p,uint salt){
|
|
uint key = uint(p.x)*0x9e3779b1u ^ uint(p.y)*0x85ebca6bu ^ salt;
|
|
return float(grainHash(key) & 255u);
|
|
}
|
|
void main(){
|
|
ivec2 pixel = ivec2(gl_FragCoord.xy);
|
|
vec3 c = texelFetch(src, pixel, 0).rgb;
|
|
float luma = c.r*0.2126+c.g*0.7152+c.b*0.0722;
|
|
float sh = 1.0-ss(0.16,0.60,luma), hi = ss(0.38,0.90,luma);
|
|
vec3 g = mix(vec3(luma), c, 1.24);
|
|
g = (g-0.5)*1.12+0.5;
|
|
g = mix(g, g*g*(3.-2.*g), 0.18);
|
|
g.r += hi*0.070+sh*0.018; g.g -= hi*0.028; g.b += sh*0.045+hi*0.018;
|
|
g = clamp(g,0.,1.);
|
|
vec3 color = mix(c, g, 0.30);
|
|
float l2 = color.r*0.2126+color.g*0.7152+color.b*0.0722;
|
|
vec3 faded = clamp(mix(vec3(l2*0.78+0.078), color*0.78+vec3(0.085,0.078,0.068), 0.88),0.,1.);
|
|
uint grainSeed = uint(seed);
|
|
float r1 = grainByte(pixel,grainSeed)-128.;
|
|
float r2 = grainByte(pixel,grainSeed^0xa511e9b3u)-128.;
|
|
float delta = clamp(trunc((r1+r2)*grainAmp/256.), -grainAmp, grainAmp)/255.;
|
|
frag = vec4(clamp(faded+delta,0.,1.), 1.0);
|
|
}`;
|
|
|
|
const FS_BLIT = `#version 300 es
|
|
precision highp float;
|
|
uniform sampler2D src;
|
|
uniform vec2 outSize;
|
|
uniform vec2 uvScale;
|
|
uniform vec2 uvOffset;
|
|
uniform float flipInput;
|
|
out vec4 frag;
|
|
void main(){
|
|
vec2 uv = gl_FragCoord.xy/outSize;
|
|
uv = uvOffset + uv*uvScale;
|
|
if (flipInput > 0.5) uv.y = 1.0-uv.y;
|
|
frag = vec4(texture(src, uv).rgb, 1.0);
|
|
}`;
|
|
|
|
// Preserve the 16:9 master like CSS object-fit cover, aligned center/top.
|
|
export function heroCoverUv(outputWidth, outputHeight) {
|
|
const sourceAspect = HERO_MASTER_WIDTH / HERO_MASTER_HEIGHT;
|
|
const outputAspect = outputWidth / outputHeight;
|
|
if (outputAspect > sourceAspect) {
|
|
const visibleHeight = sourceAspect / outputAspect;
|
|
return {
|
|
scale: [1, visibleHeight],
|
|
offset: [0, 1 - visibleHeight],
|
|
};
|
|
}
|
|
const visibleWidth = outputAspect / sourceAspect;
|
|
return {
|
|
scale: [visibleWidth, 1],
|
|
offset: [(1 - visibleWidth) / 2, 0],
|
|
};
|
|
}
|
|
|
|
function parsePayload(payload) {
|
|
const bytes = payload instanceof Uint8Array
|
|
? payload
|
|
: new Uint8Array(payload);
|
|
if (bytes.byteLength < HEADER_BYTES || !MAGIC.every((v, i) => bytes[i] === v)) {
|
|
throw new Error("invalid live hero payload");
|
|
}
|
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
const width = view.getUint32(4, true);
|
|
const height = view.getUint32(8, true);
|
|
const seed = view.getUint32(12, true);
|
|
const expected = HEADER_BYTES + width * height * 4;
|
|
if (!width || !height || !Number.isSafeInteger(expected) || expected !== bytes.byteLength) {
|
|
throw new Error("invalid live hero dimensions");
|
|
}
|
|
return { width, height, seed, rgba: bytes.subarray(HEADER_BYTES) };
|
|
}
|
|
|
|
function compile(gl, source, type) {
|
|
const shader = gl.createShader(type);
|
|
gl.shaderSource(shader, source);
|
|
gl.compileShader(shader);
|
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
const error = gl.getShaderInfoLog(shader) || "shader compilation failed";
|
|
gl.deleteShader(shader);
|
|
throw new Error(error);
|
|
}
|
|
return shader;
|
|
}
|
|
|
|
function program(gl, fragment) {
|
|
const result = gl.createProgram();
|
|
const vertex = compile(gl, VS, gl.VERTEX_SHADER);
|
|
const pixel = compile(gl, fragment, gl.FRAGMENT_SHADER);
|
|
gl.attachShader(result, vertex);
|
|
gl.attachShader(result, pixel);
|
|
gl.linkProgram(result);
|
|
gl.deleteShader(vertex);
|
|
gl.deleteShader(pixel);
|
|
if (!gl.getProgramParameter(result, gl.LINK_STATUS)) {
|
|
const error = gl.getProgramInfoLog(result) || "shader link failed";
|
|
gl.deleteProgram(result);
|
|
throw new Error(error);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function mipLevels(width, height) {
|
|
return 1 + Math.floor(Math.log2(Math.max(width, height)));
|
|
}
|
|
|
|
export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previousBloomCanvas, onLost) {
|
|
const gl = canvas.getContext("webgl2", {
|
|
alpha: false,
|
|
antialias: false,
|
|
depth: false,
|
|
stencil: false,
|
|
premultipliedAlpha: false,
|
|
preserveDrawingBuffer: true,
|
|
powerPreference: "high-performance",
|
|
});
|
|
if (!gl) throw new Error("WebGL2 is unavailable");
|
|
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
|
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
|
|
if ("drawingBufferColorSpace" in gl) gl.drawingBufferColorSpace = "srgb";
|
|
|
|
const maxTexture = gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
|
if (maxTexture < HERO_MASTER_WIDTH) {
|
|
throw new Error(`GPU texture limit ${maxTexture}px is below 4K`);
|
|
}
|
|
|
|
const programs = {
|
|
easu: program(gl, FS_EASU),
|
|
rcas: program(gl, FS_RCAS),
|
|
grade: program(gl, FS_GRADE),
|
|
blit: program(gl, FS_BLIT),
|
|
};
|
|
let master = null;
|
|
let destroyed = false;
|
|
|
|
const contextLost = (event) => {
|
|
event.preventDefault();
|
|
onLost?.(new Error("WebGL context lost"));
|
|
};
|
|
canvas.addEventListener("webglcontextlost", contextLost);
|
|
|
|
function texture(width, height, levels = 1) {
|
|
const value = gl.createTexture();
|
|
gl.bindTexture(gl.TEXTURE_2D, value);
|
|
gl.texStorage2D(gl.TEXTURE_2D, levels, gl.RGBA8, width, height);
|
|
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);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
gl.texParameteri(
|
|
gl.TEXTURE_2D,
|
|
gl.TEXTURE_MIN_FILTER,
|
|
levels > 1 ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR,
|
|
);
|
|
return value;
|
|
}
|
|
|
|
function target(width, height, levels = 1) {
|
|
const tx = texture(width, height, levels);
|
|
const fb = gl.createFramebuffer();
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
|
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tx, 0);
|
|
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
|
|
gl.deleteFramebuffer(fb);
|
|
gl.deleteTexture(tx);
|
|
throw new Error("GPU hero framebuffer is incomplete");
|
|
}
|
|
return { tx, fb, width, height, levels };
|
|
}
|
|
|
|
function disposeTarget(value, keepTexture = false) {
|
|
if (!value) return;
|
|
if (value.fb) gl.deleteFramebuffer(value.fb);
|
|
if (!keepTexture && value.tx) gl.deleteTexture(value.tx);
|
|
}
|
|
|
|
function draw(prog, src, output, uniforms = {}) {
|
|
gl.useProgram(prog);
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, output?.fb ?? null);
|
|
const width = output?.width ?? canvas.width;
|
|
const height = output?.height ?? canvas.height;
|
|
gl.viewport(0, 0, width, height);
|
|
gl.activeTexture(gl.TEXTURE0);
|
|
gl.bindTexture(gl.TEXTURE_2D, src);
|
|
gl.uniform1i(gl.getUniformLocation(prog, "src"), 0);
|
|
for (const [name, value] of Object.entries(uniforms)) {
|
|
const location = gl.getUniformLocation(prog, name);
|
|
if (location === null) continue;
|
|
if (Array.isArray(value)) gl.uniform2f(location, value[0], value[1]);
|
|
else gl.uniform1f(location, value);
|
|
}
|
|
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
const error = gl.getError();
|
|
if (error !== gl.NO_ERROR) throw new Error(`WebGL hero draw failed (${error})`);
|
|
}
|
|
|
|
function copyCanvas(from, to) {
|
|
if (!from.width || !from.height) return;
|
|
to.width = from.width;
|
|
to.height = from.height;
|
|
const context = to.getContext("2d", { alpha: false });
|
|
context.imageSmoothingEnabled = true;
|
|
context.imageSmoothingQuality = "high";
|
|
context.drawImage(from, 0, 0, to.width, to.height);
|
|
}
|
|
|
|
function snapshot() {
|
|
if (!master || !canvas.width) return false;
|
|
copyCanvas(canvas, previousCanvas);
|
|
copyCanvas(bloomCanvas, previousBloomCanvas);
|
|
return true;
|
|
}
|
|
|
|
function buildMaster(payload) {
|
|
const { width, height, seed, rgba } = parsePayload(payload);
|
|
if (width > maxTexture || height > maxTexture) {
|
|
throw new Error(`hero source ${width}x${height} exceeds the GPU texture limit`);
|
|
}
|
|
const downsample = width > HERO_MASTER_WIDTH || height > HERO_MASTER_HEIGHT;
|
|
const sourceLevels = downsample ? mipLevels(width, height) : 1;
|
|
const source = texture(width, height, sourceLevels);
|
|
const temporaries = [];
|
|
let scaled = null;
|
|
let sharpened = null;
|
|
let finished = null;
|
|
try {
|
|
gl.bindTexture(gl.TEXTURE_2D, source);
|
|
gl.texSubImage2D(
|
|
gl.TEXTURE_2D,
|
|
0,
|
|
0,
|
|
0,
|
|
width,
|
|
height,
|
|
gl.RGBA,
|
|
gl.UNSIGNED_BYTE,
|
|
rgba,
|
|
);
|
|
if (downsample) gl.generateMipmap(gl.TEXTURE_2D);
|
|
|
|
if (downsample || (width === HERO_MASTER_WIDTH && height === HERO_MASTER_HEIGHT)) {
|
|
scaled = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT);
|
|
temporaries.push(scaled);
|
|
draw(programs.blit, source, scaled, {
|
|
outSize: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT],
|
|
uvScale: [1, 1],
|
|
uvOffset: [0, 0],
|
|
flipInput: 1,
|
|
});
|
|
} else {
|
|
let currentTexture = source;
|
|
let currentWidth = width;
|
|
let currentHeight = height;
|
|
let firstPass = true;
|
|
const firstWidth = Math.min(width * 2, HERO_MASTER_WIDTH);
|
|
if (firstWidth < HERO_MASTER_WIDTH) {
|
|
const intermediate = target(
|
|
firstWidth,
|
|
Math.max(1, Math.round((height * firstWidth) / width)),
|
|
);
|
|
temporaries.push(intermediate);
|
|
draw(programs.easu, currentTexture, intermediate, {
|
|
inSize: [currentWidth, currentHeight],
|
|
outSize: [intermediate.width, intermediate.height],
|
|
flipInput: 1,
|
|
});
|
|
currentTexture = intermediate.tx;
|
|
currentWidth = intermediate.width;
|
|
currentHeight = intermediate.height;
|
|
firstPass = false;
|
|
}
|
|
scaled = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT);
|
|
temporaries.push(scaled);
|
|
draw(programs.easu, currentTexture, scaled, {
|
|
inSize: [currentWidth, currentHeight],
|
|
outSize: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT],
|
|
flipInput: firstPass ? 1 : 0,
|
|
});
|
|
}
|
|
|
|
sharpened = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT);
|
|
temporaries.push(sharpened);
|
|
draw(programs.rcas, scaled.tx, sharpened, {
|
|
size: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT],
|
|
con: Math.pow(2, -0.25),
|
|
});
|
|
|
|
finished = target(
|
|
HERO_MASTER_WIDTH,
|
|
HERO_MASTER_HEIGHT,
|
|
mipLevels(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT),
|
|
);
|
|
draw(programs.grade, sharpened.tx, finished, {
|
|
grainAmp: 20,
|
|
seed,
|
|
});
|
|
gl.bindTexture(gl.TEXTURE_2D, finished.tx);
|
|
gl.generateMipmap(gl.TEXTURE_2D);
|
|
gl.finish();
|
|
disposeTarget(finished, true);
|
|
return { tx: finished.tx, width: HERO_MASTER_WIDTH, height: HERO_MASTER_HEIGHT };
|
|
} catch (error) {
|
|
disposeTarget(finished);
|
|
throw error;
|
|
} finally {
|
|
for (const value of temporaries) disposeTarget(value);
|
|
gl.deleteTexture(source);
|
|
}
|
|
}
|
|
|
|
function copyBloom() {
|
|
if (!canvas.width || !canvas.height) return;
|
|
const cssHeight = 106;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scale = rect.height > 0 ? canvas.height / rect.height : 1;
|
|
const sourceHeight = Math.max(1, Math.min(canvas.height, Math.round(cssHeight * scale)));
|
|
bloomCanvas.width = canvas.width;
|
|
bloomCanvas.height = sourceHeight;
|
|
const context = bloomCanvas.getContext("2d", { alpha: false });
|
|
context.imageSmoothingEnabled = true;
|
|
context.imageSmoothingQuality = "high";
|
|
context.drawImage(
|
|
canvas,
|
|
0,
|
|
0,
|
|
canvas.width,
|
|
sourceHeight,
|
|
0,
|
|
0,
|
|
bloomCanvas.width,
|
|
bloomCanvas.height,
|
|
);
|
|
}
|
|
|
|
function resize() {
|
|
if (destroyed || !master) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
if (rect.width <= 0 || rect.height <= 0) return;
|
|
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
|
const scale = Math.min(
|
|
dpr,
|
|
HERO_MASTER_WIDTH / rect.width,
|
|
HERO_MASTER_HEIGHT / rect.height,
|
|
);
|
|
const width = Math.max(1, Math.round(rect.width * scale));
|
|
const height = Math.max(1, Math.round(rect.height * scale));
|
|
if (canvas.width !== width) canvas.width = width;
|
|
if (canvas.height !== height) canvas.height = height;
|
|
const cover = heroCoverUv(width, height);
|
|
draw(programs.blit, master.tx, null, {
|
|
outSize: [width, height],
|
|
uvScale: cover.scale,
|
|
uvOffset: cover.offset,
|
|
flipInput: 0,
|
|
});
|
|
gl.finish();
|
|
copyBloom();
|
|
}
|
|
|
|
function render(payload) {
|
|
if (destroyed) throw new Error("GPU hero renderer is closed");
|
|
const hadPrevious = snapshot();
|
|
const started = performance.now();
|
|
const next = buildMaster(payload);
|
|
const previous = master;
|
|
master = next;
|
|
resize();
|
|
if (previous?.tx) gl.deleteTexture(previous.tx);
|
|
return {
|
|
hadPrevious,
|
|
renderMs: performance.now() - started,
|
|
renderer: gl.getParameter(gl.RENDERER),
|
|
};
|
|
}
|
|
|
|
// Keep the compiled programs/context warm between artist navigations, but
|
|
// release the 4K master texture and canvas backing stores while there is no
|
|
// selected GPU hero. A stale master is both visually wrong and roughly
|
|
// 44 MiB once its mip chain is included.
|
|
function clear() {
|
|
if (destroyed) return;
|
|
if (master?.tx && !gl.isContextLost()) gl.deleteTexture(master.tx);
|
|
master = null;
|
|
for (const target of [canvas, bloomCanvas, previousCanvas, previousBloomCanvas]) {
|
|
target.width = 1;
|
|
target.height = 1;
|
|
}
|
|
}
|
|
|
|
function destroy() {
|
|
if (destroyed) return;
|
|
destroyed = true;
|
|
canvas.removeEventListener("webglcontextlost", contextLost);
|
|
if (master?.tx && !gl.isContextLost()) gl.deleteTexture(master.tx);
|
|
if (!gl.isContextLost()) {
|
|
for (const value of Object.values(programs)) gl.deleteProgram(value);
|
|
}
|
|
master = null;
|
|
}
|
|
|
|
return { render, resize, clear, destroy };
|
|
}
|