diff --git a/demo/distance-field-visualization.html b/demo/[shaders]distance-field.html similarity index 100% rename from demo/distance-field-visualization.html rename to demo/[shaders]distance-field.html diff --git a/demo/[shaders]falling-sand.html b/demo/[shaders]falling-sand.html new file mode 100644 index 0000000..e488c32 --- /dev/null +++ b/demo/[shaders]falling-sand.html @@ -0,0 +1,48 @@ + + + + + + Distance Field Demo + + + + + + + + + + + + + + + + diff --git a/globals.d.ts b/globals.d.ts new file mode 100644 index 0000000..74214cb --- /dev/null +++ b/globals.d.ts @@ -0,0 +1,7 @@ +import type { TransformEvent } from './src/common/TransformEvent'; + +declare global { + interface HTMLElementEventMap { + transform: TransformEvent; + } +} diff --git a/src/common/tags.ts b/src/common/tags.ts index 744e4c4..5c032a2 100644 --- a/src/common/tags.ts +++ b/src/common/tags.ts @@ -1,9 +1,5 @@ export const glsl = String.raw; -export const vert = String.raw; - -export const frag = String.raw; - export const html = String.raw; export function css(strings: TemplateStringsArray, ...values: any[]) { diff --git a/src/common/webgl.ts b/src/common/webgl.ts index 0c441bb..345b2bb 100644 --- a/src/common/webgl.ts +++ b/src/common/webgl.ts @@ -16,11 +16,13 @@ export class WebGLUtils { static createProgram( gl: WebGL2RenderingContext, vertexShader: WebGLShader, - fragmentShader: WebGLShader + fragmentShader?: WebGLShader ): WebGLProgram { const program = gl.createProgram()!; gl.attachShader(program, vertexShader); - gl.attachShader(program, fragmentShader); + if (fragmentShader) { + gl.attachShader(program, fragmentShader); + } gl.linkProgram(program); const success = gl.getProgramParameter(program, gl.LINK_STATUS); diff --git a/src/folk-distance-field.ts b/src/folk-distance-field.ts index b07ccc1..822b31e 100644 --- a/src/folk-distance-field.ts +++ b/src/folk-distance-field.ts @@ -1,6 +1,6 @@ import { DOMRectTransform } from './common/DOMRectTransform.ts'; -import { frag, vert } from './common/tags.ts'; import { Point } from './common/types.ts'; +import { glsl } from './common/tags.ts'; import { WebGLUtils } from './common/webgl.ts'; import { FolkBaseSet } from './folk-base-set.ts'; import { FolkShape } from './folk-shape.ts'; @@ -466,7 +466,7 @@ export class FolkDistanceField extends FolkBaseSet { * Vertex shader shared by multiple programs. * Transforms vertices to normalized device coordinates and passes texture coordinates to the fragment shader. */ -const commonVertShader = vert`#version 300 es +const commonVertShader = glsl`#version 300 es precision mediump float; in vec2 a_position; out vec2 v_texCoord; @@ -480,7 +480,7 @@ void main() { * Fragment shader for the Jump Flooding Algorithm. * Updates the nearest seed point and distance for each pixel by examining neighboring pixels. */ -const jfaFragShader = frag`#version 300 es +const jfaFragShader = glsl`#version 300 es precision mediump float; precision mediump int; @@ -524,7 +524,7 @@ void main() { * Fragment shader for rendering the final distance field. * Converts distances to colors for visualization. */ -const renderFragShader = frag`#version 300 es +const renderFragShader = glsl`#version 300 es precision mediump float; in vec2 v_texCoord; @@ -559,7 +559,7 @@ void main() { * Vertex shader for rendering seed points. * Outputs the shape ID to the fragment shader. */ -const seedVertShader = vert`#version 300 es +const seedVertShader = glsl`#version 300 es precision mediump float; in vec3 a_position; // x, y position and shapeID as z @@ -574,7 +574,7 @@ void main() { * Fragment shader for rendering seed points. * Initializes the texture with seed point positions and shape IDs. */ -const seedFragShader = frag`#version 300 es +const seedFragShader = glsl`#version 300 es precision mediump float; flat in float v_shapeID; diff --git a/src/folk-sand.glsl.ts b/src/folk-sand.glsl.ts new file mode 100644 index 0000000..20146a2 --- /dev/null +++ b/src/folk-sand.glsl.ts @@ -0,0 +1,757 @@ +import { glsl } from './common/tags.ts'; + +/** Falling sand shaders using block cellular automata with Margolus offsets. + * Based on "Probabilistic Cellular Automata for Granular Media in Video Games" (https://arxiv.org/abs/2008.06341) + * Code adapted from https://github.com/GelamiSalami/GPU-Falling-Sand-CA + */ + +const CONSTANTS = glsl` +#define AIR 0.0 +#define SMOKE 1.0 +#define WATER 2.0 +#define LAVA 3.0 +#define SAND 4.0 +#define PLANT 5.0 +#define STONE 6.0 +#define WALL 7.0 +#define COLLISION 99.0 + +const vec3 bgColor = pow(vec3(31, 34, 36) / 255.0, vec3(2)); +`; + +const UTILS = glsl` + +const float EPSILON = 1e-4; + +const float PI = acos(-1.); +const float TAU = PI * 2.0; + +float safeacos(float x) { return acos(clamp(x, -1.0, 1.0)); } + +float saturate(float x) { return clamp(x, 0., 1.); } +vec2 saturate(vec2 x) { return clamp(x, vec2(0), vec2(1)); } +vec3 saturate(vec3 x) { return clamp(x, vec3(0), vec3(1)); } + +float sqr(float x) { return x*x; } +vec2 sqr(vec2 x) { return x*x; } +vec3 sqr(vec3 x) { return x*x; } + +float luminance(vec3 col) { return dot(col, vec3(0.2126729, 0.7151522, 0.0721750)); } + +mat2 rot2D(float a) +{ + float c = cos(a); + float s = sin(a); + return mat2(c, s, -s, c); +} + +// https://iquilezles.org/articles/smin/ +float smin( float d1, float d2, float k ) { + float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 ); + return mix( d2, d1, h ) - k*h*(1.0-h); +} + +float smax( float d1, float d2, float k ) { + float h = clamp( 0.5 - 0.5*(d2-d1)/k, 0.0, 1.0 ); + return mix( d2, d1, h ) + k*h*(1.0-h); +} + +// https://iquilezles.org/articles/palettes/ +vec3 palette(float t) +{ + return .5 + .5 * cos(TAU * (vec3(1, 1, 1) * t + vec3(0, .33, .67))); +} + +// Hash without Sine +// https://www.shadertoy.com/view/4djSRW +float hash12(vec2 p) +{ + vec3 p3 = fract(vec3(p.xyx) * .1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +float hash13(vec3 p3) +{ + p3 = fract(p3 * .1031); + p3 += dot(p3, p3.zyx + 31.32); + return fract((p3.x + p3.y) * p3.z); +} + +vec2 hash22(vec2 p) +{ + vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973)); + p3 += dot(p3, p3.yzx+33.33); + return fract((p3.xx+p3.yz)*p3.zy); +} + +vec2 hash23(vec3 p3) +{ + p3 = fract(p3 * vec3(.1031, .1030, .0973)); + p3 += dot(p3, p3.yzx+33.33); + return fract((p3.xx+p3.yz)*p3.zy); +} + +vec3 hash33(vec3 p3) +{ + p3 = fract(p3 * vec3(.1031, .1030, .0973)); + p3 += dot(p3, p3.yxz+33.33); + return fract((p3.xxy + p3.yxx)*p3.zyx); +} + +vec4 hash43(vec3 p) +{ + vec4 p4 = fract(vec4(p.xyzx) * vec4(.1031, .1030, .0973, .1099)); + p4 += dot(p4, p4.wzxy+33.33); + return fract((p4.xxyz+p4.yzzw)*p4.zywx); +} + +// https://www.chilliant.com/rgb2hsv.html +vec3 RGBtoHCV(in vec3 RGB) +{ + // Based on work by Sam Hocevar and Emil Persson + vec4 P = (RGB.g < RGB.b) ? vec4(RGB.bg, -1.0, 2.0/3.0) : vec4(RGB.gb, 0.0, -1.0/3.0); + vec4 Q = (RGB.r < P.x) ? vec4(P.xyw, RGB.r) : vec4(RGB.r, P.yzx); + float C = Q.x - min(Q.w, Q.y); + float H = abs((Q.w - Q.y) / (6.0 * C + EPSILON) + Q.z); + return vec3(H, C, Q.x); +} + +vec3 RGBtoHSV(in vec3 RGB) +{ + vec3 HCV = RGBtoHCV(RGB); + float S = HCV.y / (HCV.z + EPSILON); + return vec3(HCV.x, S, HCV.z); +} + +vec3 RGBtoHSL(in vec3 RGB) +{ + vec3 HCV = RGBtoHCV(RGB); + float L = HCV.z - HCV.y * 0.5; + float S = HCV.y / (1.0 - abs(L * 2.0 - 1.0) + EPSILON); + return vec3(HCV.x, S, L); +} + +vec3 HUEtoRGB(in float H) +{ + float R = abs(H * 6.0 - 3.0) - 1.0; + float G = 2.0 - abs(H * 6.0 - 2.0); + float B = 2.0 - abs(H * 6.0 - 4.0); + return saturate(vec3(R,G,B)); +} + +vec3 HSVtoRGB(in vec3 HSV) +{ + vec3 RGB = HUEtoRGB(HSV.x); + return ((RGB - 1.0) * HSV.y + 1.0) * HSV.z; +} + +vec3 HSLtoRGB(in vec3 HSL) +{ + vec3 RGB = HUEtoRGB(HSL.x); + float C = (1.0 - abs(2.0 * HSL.z - 1.0)) * HSL.y; + return (RGB - 0.5) * C + HSL.z; +} + +vec3 sRGBToLinear(vec3 col) +{ + return mix(pow((col + 0.055) / 1.055, vec3(2.4)), col / 12.92, lessThan(col, vec3(0.04045))); +} + +vec3 linearTosRGB(vec3 col) +{ + return mix(1.055 * pow(col, vec3(1.0 / 2.4)) - 0.055, col * 12.92, lessThan(col, vec3(0.0031308))); +} +`; + +/** Vertex shader for rendering quads */ +export const vertexShader = glsl`#version 300 es +in vec4 aPosition; +in vec2 aUv; + +out vec2 outUv; + +void main() { + gl_Position = aPosition; + outUv = aUv; +} +`; + +export const simulationShader = glsl`#version 300 es +precision mediump float; + +uniform vec2 resolution; +uniform float time; +uniform int frame; +uniform vec4 mouse; +uniform int materialType; +uniform float brushRadius; +uniform sampler2D tex; +uniform sampler2D u_collisionTex; + +in vec2 outUv; + +out vec4 fragColor; + +${CONSTANTS} +${UTILS} + +// https://iquilezles.org/articles/distfunctions2d/ +float sdSegment(vec2 p, vec2 a, vec2 b) +{ + vec2 pa = p-a, ba = b-a; + float h = clamp( dot(pa,ba) / dot(ba,ba), 0.0, 1.0 ); + return length( pa - ba*h ); +} + +ivec2 getOffset(int frame) +{ + int i = frame % 4; + if (i == 0) + return ivec2(0, 0); + else if (i == 1) + return ivec2(1, 1); + else if (i == 2) + return ivec2(0, 1); + return ivec2(1, 0); +} + +vec4 getData(ivec2 p) +{ + // Check boundaries first + if (p.x < 0 || p.y < 0 || p.x >= int(resolution.x) || p.y >= int(resolution.y)) { + return vec4(vec3(0.02), WALL); + } + + // Calculate UV coordinates for the collision texture + vec2 collisionUv = (vec2(p) + 0.5) / resolution; + float collisionValue = texture(u_collisionTex, collisionUv).r; + + // If there's a collision at this position, always return COLLISION type + if (collisionValue > 0.5) { + return vec4(bgColor, COLLISION); + } + + // If no collision, get the data from the simulation texture + vec4 data = texelFetch(tex, p, 0); + if (data.xyz == vec3(0)) { + data.xyz = bgColor; + } + return data; +} + +void swap(inout vec4 a, inout vec4 b) +{ + vec4 tmp = a; + a = b; + b = tmp; +} + +vec4 createParticle(float id) +{ + if (id == AIR) + { + return vec4(bgColor, AIR); + } else if (id == SMOKE) + { + return vec4(mix(bgColor, vec3(0.15), 0.5), SMOKE); + } else if (id == WATER) + { + return vec4(mix(bgColor, vec3(0.15, 0.45, 0.9), 0.7), WATER); + } else if (id == LAVA) + { + vec3 r = hash33(vec3(gl_FragCoord.xy, frame)); + vec3 color = vec3(255, 40, 20) / 255.0; + vec3 hsl = RGBtoHSL(color); + hsl.x += (r.z - 0.5) * 12.0 / 255.0; + hsl.y += (r.x - 0.5) * 16.0 / 255.0; + hsl.z *= (r.y * 80.0 / 255.0 + (255.0 - 80.0) / 255.0); + return vec4(HSLtoRGB(hsl), LAVA); + } else if (id == SAND) + { + vec3 r = hash33(vec3(gl_FragCoord.xy, frame)); + vec3 color = vec3(220, 158, 70) / 255.0; + vec3 hsl = RGBtoHSL(color); + hsl.x += (r.z - 0.5) * 12.0 / 255.0; + hsl.y += (r.x - 0.5) * 16.0 / 255.0; + hsl.z += (r.y - 0.5) * 40.0 / 255.0; + return vec4(HSLtoRGB(hsl), SAND); + } else if (id == PLANT) + { + vec3 r = hash33(vec3(gl_FragCoord.xy, frame)); + vec3 color = vec3(34, 139, 34) / 255.0; + vec3 hsl = RGBtoHSL(color); + hsl.x += (r.z - 0.5) * 0.1; + hsl.y += (r.x - 0.5) * 0.2; + hsl.z *= (r.y * 0.4 + 0.6); + return vec4(HSLtoRGB(hsl), PLANT); + } else if (id == STONE) + { + float r = hash13(vec3(gl_FragCoord.xy, frame)); + return vec4(vec3(0.08, 0.1, 0.12) * (r * 0.5 + 0.5), STONE); + } else if (id == WALL) + { + float r = hash13(vec3(gl_FragCoord.xy, frame)); + return vec4(bgColor * 0.5 * (r * 0.4 + 0.6), WALL); + } + return vec4(bgColor, AIR); +} + +void main() { + vec2 uv = gl_FragCoord.xy / resolution; + + if (frame == 0) { + float r = hash12(gl_FragCoord.xy); + float id = AIR; + if (r < 0.15) + { + id = SAND; + } else if (r < 0.25) + { + id = SMOKE; + } + + fragColor = createParticle(id); + return; + } + + if (mouse.x > 0.0) + { + float d = sdSegment(gl_FragCoord.xy, mouse.xy, mouse.zw); + if (d < brushRadius) + { + fragColor = createParticle(float(materialType)); + return; + } + } + + ivec2 offset = getOffset(frame); + ivec2 fc = ivec2(gl_FragCoord.xy) + offset; + ivec2 p = (fc / 2) * 2 - offset; + ivec2 xy = fc % 2; + int i = xy.x + xy.y * 2; + + vec4 t00 = getData(p); // top-left + vec4 t10 = getData(p + ivec2(1, 0)); // top-right + vec4 t01 = getData(p + ivec2(0, 1)); // bottom-left + vec4 t11 = getData(p + ivec2(1, 1)); // bottom-right + + vec4 tn00 = getData(p + ivec2(0, -1)); + vec4 tn10 = getData(p + ivec2(1, -1)); + + if (t00.a == t10.a && t01.a == t11.a && t00.a == t01.a) + { + fragColor = i == 0 ? t00 : + i == 1 ? t10 : + i == 2 ? t01 : t11; + return; + } + + vec4 r = hash43(vec3(p, frame)); + + if ((t01.a == SMOKE && t11.a < SMOKE || + t01.a < SMOKE && t11.a == SMOKE) && r.x < 0.25) + { + swap(t01, t11); + } + + if (t00.a == SMOKE) + { + if (t01.a < t00.a && r.y < 0.25) + { + swap(t00, t01); + } else if (r.z < 0.003) + { + t00 = vec4(bgColor, AIR); + } + } + if (t10.a == SMOKE) + { + if (t11.a < t10.a && r.y < 0.25) + { + swap(t10, t11); + } else if (r.z < 0.003) + { + t10 = vec4(bgColor, AIR); + } + } + + if (((t01.a == SAND) && t11.a < SAND || + t01.a < SAND && (t11.a == SAND)) && + t00.a < SAND && t10.a < SAND && r.x < 0.4) + { + swap(t01, t11); + } + + if (t01.a == SAND || t01.a == STONE) + { + if (t00.a < SAND) + { + if (r.y < 0.9) swap(t01, t00); + } else if (t11.a < SAND && t10.a < SAND) + { + swap(t01, t10); + } + } + + if (t11.a == SAND || t11.a == STONE) + { + if (t10.a < SAND) + { + if (r.y < 0.9) swap(t11, t10); + } else if (t01.a < SAND && t00.a < SAND) + { + swap(t11, t00); + } + } + + bool drop = false; + if (t01.a == WATER) + { + if (t00.a < t01.a && r.y < 0.95) + { + swap(t01, t00); + drop = true; + } else if (t11.a < t01.a && t10.a < t01.a && r.z < 0.3) + { + swap(t01, t10); + drop = true; + } + } + if (t11.a == WATER) + { + if (t10.a < t11.a && r.y < 0.95) + { + swap(t11, t10); + drop = true; + } else if (t01.a < t11.a && t00.a < t11.a && r.z < 0.3) + { + swap(t11, t00); + drop = true; + } + } + + if (!drop) + { + if ((t01.a == WATER && t11.a < WATER || + t01.a < WATER && t11.a == WATER) && + (t00.a >= WATER && t10.a >= WATER || r.w < 0.8)) + { + swap(t01, t11); + } + if ((t00.a == WATER && t10.a < WATER || + t00.a < WATER && t10.a == WATER) && + (tn00.a >= WATER && tn10.a >= WATER || r.w < 0.8)) + { + swap(t00, t10); + } + } + + if (t01.a == LAVA) + { + if (t00.a < t01.a && r.y < 0.8) + { + swap(t01, t00); + } else if (t11.a < t01.a && t10.a < t01.a && r.z < 0.2) + { + swap(t01, t10); + } + } + if (t11.a == LAVA) + { + if (t10.a < t11.a && r.y < 0.8) + { + swap(t11, t10); + } else if (t01.a < t11.a && t00.a < t11.a && r.z < 0.2) + { + swap(t11, t00); + } + } + + if (t00.a == LAVA) + { + if (t01.a == WATER) + { + t00 = createParticle(STONE); + t01 = createParticle(SMOKE); + } else if (t10.a == WATER) + { + t00 = createParticle(STONE); + t10 = createParticle(SMOKE); + } else if (t01.a == PLANT && r.x < 1.0) // left + { + t00 = createParticle(LAVA); + t01 = createParticle(SMOKE); + } else if (t10.a == PLANT && r.x < 1.0) // right + { + t00 = createParticle(LAVA); + t10 = createParticle(SMOKE); + } else if (t00.a == PLANT && r.x < 1.0) // bottom + { + t00 = createParticle(LAVA); + t00 = createParticle(SMOKE); + } + } + if (t10.a == LAVA) + { + if (t11.a == WATER) + { + t10 = createParticle(STONE); + t11 = createParticle(SMOKE); + } else if (t00.a == WATER) + { + t10 = createParticle(STONE); + t00 = createParticle(SMOKE); + } + + else if (t11.a == PLANT) + { + t10 = createParticle(LAVA); + t11 = createParticle(SMOKE); + } else if (t00.a == PLANT) + { + t10 = createParticle(LAVA); + t00 = createParticle(SMOKE); + } + } + + if ((t01.a == LAVA && t11.a < LAVA || + t01.a < LAVA && t11.a == LAVA) && r.x < 0.6) + { + swap(t01, t11); + } + + + fragColor = i == 0 ? t00 : + i == 1 ? t10 : + i == 2 ? t01 : t11; + + if (fragColor.a == COLLISION) { + vec2 collisionUv = gl_FragCoord.xy / resolution; + float collisionValue = texture(u_collisionTex, collisionUv).r; + if (collisionValue <= 0.5) { + fragColor = vec4(bgColor, AIR); + } + } +} +`; + +export const distanceFieldInitShader = glsl`#version 300 es +precision highp float; + +uniform vec2 resolution; +uniform sampler2D dataTex; + +${CONSTANTS} + +layout(location = 0) out vec4 fragColorR; +layout(location = 1) out vec4 fragColorG; +layout(location = 2) out vec4 fragColorB; + +void main() +{ + vec2 uv = gl_FragCoord.xy / resolution; + + vec4 data = texture(dataTex, uv); + + fragColorR = vec4(-1, -1, 0, 0); + fragColorG = vec4(-1, -1, 0, 0); + fragColorB = vec4(-1, -1, 0, 0); + + if (data.a <= LAVA) + { + fragColorR.xy = gl_FragCoord.xy; + fragColorG.xy = gl_FragCoord.xy; + fragColorB.xy = gl_FragCoord.xy; + } + if (data.a == SMOKE) + { + fragColorR.w = 6.0; + fragColorG.w = 6.0; + fragColorB.w = 6.0; + } else if (data.a == WATER) + { + fragColorR.w = 9.0; + fragColorG.w = 6.0; + fragColorB.w = 4.0; + } else if (data.a == LAVA) + { + fragColorR.w = 0.0; + fragColorG.w = 11.0; + fragColorB.w = 14.0; + } else if (data.a == PLANT) + { + fragColorR.w = 4.0 - data.r * 4.0; + fragColorG.w = 4.0 - data.g * 4.0; + fragColorB.w = 4.0 - data.b * 4.0; + } +} +`; + +export const distanceFieldPropagationShader = glsl`#version 300 es +precision highp float; + +uniform float stepSize; +uniform vec2 resolution; +uniform sampler2D texR; +uniform sampler2D texG; +uniform sampler2D texB; + +uniform int passCount; +uniform int passIndex; + +layout(location = 0) out vec4 fragColorR; +layout(location = 1) out vec4 fragColorG; +layout(location = 2) out vec4 fragColorB; + +void main() +{ + vec2 fc = gl_FragCoord.xy; + + vec4 bestR = vec4(0,0,1e3,0); + vec4 bestG = vec4(0,0,1e3,0); + vec4 bestB = vec4(0,0,1e3,0); + + for (int x = -1; x <= 1; x++) + { + for (int y = -1; y <= 1; y++) + { + vec2 p = fc + vec2(x, y) * stepSize; + + vec4 dataR = texture(texR, p / resolution); + vec4 dataG = texture(texG, p / resolution); + vec4 dataB = texture(texB, p / resolution); + + if (dataR.xy != vec2(-1) && dataR.xy == clamp(dataR.xy, vec2(0.5), resolution-0.5)) + { + float dist = distance(fc, dataR.xy) + dataR.w; + if (dist < bestR.z) + { + bestR = dataR; + bestR.z = dist; + } + } + if (dataG.xy != vec2(-1) && dataG.xy == clamp(dataG.xy, vec2(0.5), resolution-0.5)) + { + float dist = distance(fc, dataG.xy) + dataG.w; + if (dist < bestG.z) + { + bestG = dataG; + bestG.z = dist; + } + } + if (dataB.xy != vec2(-1) && dataB.xy == clamp(dataB.xy, vec2(0.5), resolution-0.5)) + { + float dist = distance(fc, dataB.xy) + dataB.w; + if (dist < bestB.z) + { + bestB = dataB; + bestB.z = dist; + } + } + } + } + + fragColorR = vec4(bestR.xy, bestR.z != 1e3 ? bestR.z : 1e3, bestR.w); + fragColorG = vec4(bestG.xy, bestG.z != 1e3 ? bestG.z : 1e3, bestG.w); + fragColorB = vec4(bestB.xy, bestB.z != 1e3 ? bestB.z : 1e3, bestB.w); + + if (passIndex == passCount - 1) + { + if (bestR.xy == vec2(-1)) + fragColorR.z = 1e3; + if (bestG.xy == vec2(-1)) + fragColorG.z = 1e3; + if (bestB.xy == vec2(-1)) + fragColorB.z = 1e3; + } +} +`; + +export const visualizationShader = glsl`#version 300 es +precision highp float; + +uniform vec2 texResolution; +uniform float texScale; +uniform vec2 resolution; +uniform sampler2D tex; +uniform sampler2D shadowTexR; +uniform sampler2D shadowTexG; +uniform sampler2D shadowTexB; +uniform sampler2D u_collisionTex; +uniform float scale; + +${CONSTANTS} + +out vec4 fragColor; + +vec2 getCoordsAA(vec2 uv) +{ + float w = 1.5; // 1.5 + vec2 fl = floor(uv + 0.5); + vec2 fr = fract(uv + 0.5); + vec2 aa = fwidth(uv) * w * 0.5; + fr = smoothstep(0.5 - aa, 0.5 + aa, fr); + + return fl + fr - 0.5; +} + +vec3 linearTosRGB(vec3 col) +{ + return mix(1.055 * pow(col, vec3(1.0 / 2.4)) - 0.055, col * 12.92, lessThan(col, vec3(0.0031308))); +} + +void main() { + vec2 uv = gl_FragCoord.xy / (texResolution * texScale); + + uv -= 0.5; + uv *= scale; + uv += 0.5; + + vec2 fc = uv * texResolution; + + vec4 data = texture(tex, getCoordsAA(fc) / texResolution); + vec4 dataUp = texture(tex, getCoordsAA(fc + vec2(0, 1)) / texResolution); + vec4 dataDown = texture(tex, getCoordsAA(fc - vec2(0, 1)) / texResolution); + + float hig = float(data.a > dataUp.a); + float dropSha = 1.0 - float(data.a > dataDown.a); + + vec3 color = data.rgb == vec3(0) ? bgColor : data.rgb; + + vec4 shaDataR = texture(shadowTexR, uv); + vec4 shaDataG = texture(shadowTexG, uv); + vec4 shaDataB = texture(shadowTexB, uv); + + float shaR = shaDataR.xy != vec2(-1) ? shaDataR.z : 16.0; + float shaG = shaDataG.xy != vec2(-1) ? shaDataG.z : 16.0; + float shaB = shaDataB.xy != vec2(-1) ? shaDataB.z : 16.0; + + vec3 sha = clamp(1.0 - vec3(shaR, shaG, shaB) / 16.0, vec3(0.0), vec3(1.0)); + sha *= sha; + + color *= 0.5 * max(hig, dropSha) + 0.5; + color *= sha * 1.0 + 0.2; + color += color * 0.4 * hig; + + fragColor = vec4(linearTosRGB(color), 1); +} +`; + +export const collisionVertexShader = glsl`#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPosition; + +void main() { + gl_Position = vec4(aPosition, 0.0, 1.0); +}`; + +export const collisionFragmentShader = glsl`#version 300 es +precision highp float; + +out vec4 fragColor; + +void main() { + fragColor = vec4(1.0, 0.0, 0.0, 1.0); // red represents solid +}`; diff --git a/src/folk-sand.ts b/src/folk-sand.ts new file mode 100644 index 0000000..ffb2e44 --- /dev/null +++ b/src/folk-sand.ts @@ -0,0 +1,712 @@ +import { WebGLUtils } from './common/webgl'; +import { + collisionFragmentShader, + collisionVertexShader, + simulationShader, + distanceFieldInitShader, + distanceFieldPropagationShader, + visualizationShader, + vertexShader, +} from './folk-sand.glsl.ts'; +import { FolkShape } from './folk-shape.ts'; + +export class FolkSand extends HTMLElement { + static tagName = 'folk-sand'; + + private canvas!: HTMLCanvasElement; + private gl!: WebGL2RenderingContext; + + private program!: WebGLProgram; + private blitProgram!: WebGLProgram; + private jfaShadowProgram!: WebGLProgram; + private jfaInitProgram!: WebGLProgram; + + private vao!: WebGLVertexArrayObject; + private posBuffer!: WebGLBuffer; + + private bufferWidth!: number; + private bufferHeight!: number; + + private fbo: WebGLFramebuffer[] = []; + private tex: WebGLTexture[] = []; + + private shadowFbo: WebGLFramebuffer[] = []; + private shadowTexR: WebGLTexture[] = []; + private shadowTexG: WebGLTexture[] = []; + private shadowTexB: WebGLTexture[] = []; + + private pointer = { + x: -1, + y: -1, + prevX: -1, + prevY: -1, + down: false, + }; + + private materialType = 4; + private brushRadius = 5; + + private shapes: NodeListOf = document.querySelectorAll('folk-shape'); + + private frames = 0; + private swap = 0; + private shadowSwap = 0; + + private PIXELS_PER_PARTICLE = 4; + private PIXEL_RATIO = window.devicePixelRatio || 1; + + private collisionProgram!: WebGLProgram; + private collisionFbo!: WebGLFramebuffer; + private collisionTex!: WebGLTexture; + private shapeVao!: WebGLVertexArrayObject; + private shapePositionBuffer!: WebGLBuffer; + private shapeIndexBuffer!: WebGLBuffer; + private shapeIndexCount = 0; + + static define() { + if (customElements.get(this.tagName)) return; + FolkShape.define(); + customElements.define(this.tagName, this); + } + + connectedCallback() { + this.setupCanvas(); + this.initializeWebGL(); + this.initializeSimulation(); + this.initializeCollisionDetection(); + + // Collect all FolkShape elements + this.shapes = document.querySelectorAll('folk-shape'); + + // Attach event listeners to shapes + this.shapes.forEach((shape) => { + shape.addEventListener('transform', this.handleShapeTransform); + }); + + // Initialize collision texture with current shapes + this.collectShapeData(); + this.updateCollisionTexture(); + + this.attachEventListeners(); + requestAnimationFrame(this.render.bind(this)); + } + + disconnectedCallback() { + this.detachEventListeners(); + + // Remove event listeners from shapes + this.shapes.forEach((shape) => { + shape.removeEventListener('transform', this.handleShapeTransform); + }); + } + + private setupCanvas() { + this.canvas = document.createElement('canvas'); + this.canvas.id = 'main-canvas'; + this.canvas.style.width = '100%'; + this.canvas.style.height = '100%'; + this.canvas.style.display = 'block'; + this.style.display = 'block'; + this.style.width = '100%'; + this.style.height = '100%'; + this.appendChild(this.canvas); + } + + private initializeWebGL() { + this.gl = this.canvas.getContext('webgl2')!; + if (!this.gl) { + console.error('WebGL2 context not available!'); + } + + if (!this.gl.getExtension('EXT_color_buffer_float')) { + console.error('need EXT_color_buffer_float'); + } + + if (!this.gl.getExtension('OES_texture_float_linear')) { + console.error('need OES_texture_float_linear'); + } + } + + private initializeSimulation() { + const gl = this.gl; + + // Create shaders and programs + this.program = this.createProgramFromStrings({ + vertex: vertexShader, + fragment: simulationShader, + })!; + this.blitProgram = this.createProgramFromStrings({ + vertex: vertexShader, + fragment: visualizationShader, + })!; + this.jfaShadowProgram = this.createProgramFromStrings({ + vertex: vertexShader, + fragment: distanceFieldPropagationShader, + })!; + this.jfaInitProgram = this.createProgramFromStrings({ + vertex: vertexShader, + fragment: distanceFieldInitShader, + })!; + + // Setup buffers and vertex arrays + this.setupBuffers(); + + // Initialize framebuffers and textures + this.initializeFramebuffers(); + } + + private initializeCollisionDetection() { + const gl = this.gl; + + const collisionProgram = this.createProgramFromStrings({ + vertex: collisionVertexShader, + fragment: collisionFragmentShader, + }); + + if (!collisionProgram) { + console.error('Failed to create collision program'); + return; + } + + // Create collision shader program + this.collisionProgram = collisionProgram!; + + // Create collision texture + this.collisionTex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, this.collisionTex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, this.bufferWidth, this.bufferHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + 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 collision framebuffer + this.collisionFbo = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, this.collisionFbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.collisionTex, 0); + + // Initialize shape buffers with larger initial sizes + this.shapeVao = gl.createVertexArray()!; + gl.bindVertexArray(this.shapeVao); + + this.shapePositionBuffer = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, this.shapePositionBuffer); + // Allocate space for up to 100 shapes (400 vertices) + gl.bufferData(gl.ARRAY_BUFFER, 4 * 2 * 100 * Float32Array.BYTES_PER_ELEMENT, gl.DYNAMIC_DRAW); + + this.shapeIndexBuffer = gl.createBuffer()!; + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.shapeIndexBuffer); + // Allocate space for up to 100 shapes (600 indices) + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, 6 * 100 * Uint16Array.BYTES_PER_ELEMENT, gl.DYNAMIC_DRAW); + + // Set up vertex attributes + const posAttribLoc = gl.getAttribLocation(this.collisionProgram, 'aPosition'); + gl.enableVertexAttribArray(posAttribLoc); + gl.vertexAttribPointer(posAttribLoc, 2, gl.FLOAT, false, 0, 0); + + gl.bindVertexArray(null); + + // Initial collection and render of shape data + this.collectShapeData(); + this.updateCollisionTexture(); + } + + private setupBuffers() { + const gl = this.gl; + const quad = [-1.0, -1.0, 0.0, 0.0, -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 0.0]; + + this.posBuffer = gl.createBuffer()!; + + gl.bindBuffer(gl.ARRAY_BUFFER, this.posBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(quad), gl.STATIC_DRAW); + + this.vao = gl.createVertexArray()!; + + gl.bindVertexArray(this.vao); + + const posAttribLoc = gl.getAttribLocation(this.program, 'aPosition'); + const uvAttribLoc = gl.getAttribLocation(this.program, 'aUv'); + + gl.vertexAttribPointer(posAttribLoc, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(posAttribLoc); + + gl.vertexAttribPointer(uvAttribLoc, 2, gl.FLOAT, false, 16, 8); + gl.enableVertexAttribArray(uvAttribLoc); + } + + private initializeFramebuffers() { + const gl = this.gl; + + this.resizeCanvas(); + gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); + + this.bufferWidth = Math.ceil(gl.canvas.width / this.PIXELS_PER_PARTICLE); + this.bufferHeight = Math.ceil(gl.canvas.height / this.PIXELS_PER_PARTICLE); + + // Initialize framebuffers and textures for simulation + for (let i = 0; i < 2; i++) { + // Create textures + this.tex[i] = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, this.tex[i]); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, this.bufferWidth, this.bufferHeight, 0, gl.RGBA, gl.FLOAT, null); + + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + 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 framebuffers + this.fbo[i] = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo[i]); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.tex[i], 0); + + // Setup shadow textures + this.shadowTexR[i] = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, this.shadowTexR[i]); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, this.bufferWidth, this.bufferHeight, 0, gl.RGBA, gl.FLOAT, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + 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); + + this.shadowTexG[i] = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, this.shadowTexG[i]); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, this.bufferWidth, this.bufferHeight, 0, gl.RGBA, gl.FLOAT, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + 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); + + this.shadowTexB[i] = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, this.shadowTexB[i]); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, this.bufferWidth, this.bufferHeight, 0, gl.RGBA, gl.FLOAT, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + 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); + + // Setup shadow framebuffers + this.shadowFbo[i] = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFbo[i]); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.shadowTexR[i], 0); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT1, gl.TEXTURE_2D, this.shadowTexG[i], 0); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT2, gl.TEXTURE_2D, this.shadowTexB[i], 0); + + // Set up draw buffers for the shadow FBO + gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1, gl.COLOR_ATTACHMENT2]); + } + } + + private attachEventListeners() { + this.handlePointerDown = this.handlePointerDown.bind(this); + this.handlePointerMove = this.handlePointerMove.bind(this); + this.handlePointerUp = this.handlePointerUp.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + + this.canvas.addEventListener('pointerdown', this.handlePointerDown); + this.canvas.addEventListener('pointermove', this.handlePointerMove); + this.canvas.addEventListener('pointerup', this.handlePointerUp); + document.addEventListener('keydown', this.handleKeyDown); + } + + private detachEventListeners() { + this.canvas.removeEventListener('pointerdown', this.handlePointerDown); + this.canvas.removeEventListener('pointermove', this.handlePointerMove); + this.canvas.removeEventListener('pointerup', this.handlePointerUp); + document.removeEventListener('keydown', this.handleKeyDown); + } + + private handlePointerMove(event: PointerEvent) { + const rect = this.canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + // Update previous position before setting new position + this.pointer.prevX = this.pointer.x; + this.pointer.prevY = this.pointer.y; + + // Scale coordinates relative to canvas size + this.pointer.x = (x / rect.width) * this.canvas.width; + this.pointer.y = (y / rect.height) * this.canvas.height; + } + + private handlePointerDown(event: PointerEvent) { + const rect = this.canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + // Scale coordinates relative to canvas size + this.pointer.x = (x / rect.width) * this.canvas.width; + this.pointer.y = (y / rect.height) * this.canvas.height; + this.pointer.prevX = this.pointer.x; + this.pointer.prevY = this.pointer.y; + this.pointer.down = true; + } + + private handlePointerUp() { + this.pointer.down = false; + } + + private handleKeyDown(event: KeyboardEvent) { + const key = parseInt(event.key); + if (!isNaN(key)) { + this.setMaterialType(key); + } + } + + private setMaterialType(type: number) { + this.materialType = Math.min(Math.max(type, 0), 9); + } + + private resizeCanvas() { + const width = (this.canvas.clientWidth * this.PIXEL_RATIO) | 0; + const height = (this.canvas.clientHeight * this.PIXEL_RATIO) | 0; + if (this.canvas.width !== width || this.canvas.height !== height) { + this.canvas.width = width; + this.canvas.height = height; + return true; + } + return false; + } + + private render(time: number) { + if (this.resizeCanvas()) { + this.processResize(); + } + + this.simulationPass(time); + this.shadowPass(); + this.jfaPass(); + this.renderPass(time); + + this.pointer.prevX = this.pointer.x; + this.pointer.prevY = this.pointer.y; + + requestAnimationFrame(this.render.bind(this)); + } + + private renderPass(time: number) { + const gl = this.gl; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.drawBuffers([gl.BACK]); + gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); + + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + + gl.useProgram(this.blitProgram); + gl.bindVertexArray(this.vao); + + const timeLoc = gl.getUniformLocation(this.blitProgram, 'time'); + const resLoc = gl.getUniformLocation(this.blitProgram, 'resolution'); + const texLoc = gl.getUniformLocation(this.blitProgram, 'tex'); + const shadowTexLoc = gl.getUniformLocation(this.blitProgram, 'shadowTexR'); + const shadowTexGLoc = gl.getUniformLocation(this.blitProgram, 'shadowTexG'); + const shadowTexBLoc = gl.getUniformLocation(this.blitProgram, 'shadowTexB'); + const scaleLoc = gl.getUniformLocation(this.blitProgram, 'scale'); + const texResLoc = gl.getUniformLocation(this.blitProgram, 'texResolution'); + const texScaleLoc = gl.getUniformLocation(this.blitProgram, 'texScale'); + + gl.uniform1f(timeLoc, time * 0.001); + gl.uniform2f(resLoc, gl.canvas.width, gl.canvas.height); + gl.uniform2f(texResLoc, this.bufferWidth, this.bufferHeight); + gl.uniform1f(texScaleLoc, this.PIXELS_PER_PARTICLE); + gl.uniform1f(scaleLoc, 1.0); + + gl.uniform1i(texLoc, 0); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.tex[this.swap]); + + gl.uniform1i(shadowTexLoc, 1); + gl.uniform1i(shadowTexGLoc, 2); + gl.uniform1i(shadowTexBLoc, 3); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.shadowTexR[1 - this.shadowSwap]); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.shadowTexG[1 - this.shadowSwap]); + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this.shadowTexB[1 - this.shadowSwap]); + + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.collisionTex); + + gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); + } + + private simulationPass(time: number) { + const gl = this.gl; + gl.useProgram(this.program); + gl.bindVertexArray(this.vao); + + const timeLoc = gl.getUniformLocation(this.program, 'time'); + const frameLoc = gl.getUniformLocation(this.program, 'frame'); + const resLoc = gl.getUniformLocation(this.program, 'resolution'); + const texLoc = gl.getUniformLocation(this.program, 'tex'); + const mouseLoc = gl.getUniformLocation(this.program, 'mouse'); + const materialTypeLoc = gl.getUniformLocation(this.program, 'materialType'); + const brushRadiusLoc = gl.getUniformLocation(this.program, 'brushRadius'); + const collisionTexLoc = gl.getUniformLocation(this.program, 'u_collisionTex'); + if (!collisionTexLoc) { + console.error('Could not find u_collisionTex uniform 1'); + } + if (collisionTexLoc !== null) { + gl.uniform1i(collisionTexLoc, 5); // Use texture unit 5 + gl.activeTexture(gl.TEXTURE5); + gl.bindTexture(gl.TEXTURE_2D, this.collisionTex); + } + + let mx = (this.pointer.x / gl.canvas.width) * this.bufferWidth; + let my = (1.0 - this.pointer.y / gl.canvas.height) * this.bufferHeight; + let mpx = (this.pointer.prevX / gl.canvas.width) * this.bufferWidth; + let mpy = (1.0 - this.pointer.prevY / gl.canvas.height) * this.bufferHeight; + + let pressed = false; + + gl.uniform1f(timeLoc, time * 0.001); + gl.uniform2f(resLoc, this.bufferWidth, this.bufferHeight); + gl.uniform1i(materialTypeLoc, this.materialType); + gl.uniform1f(brushRadiusLoc, this.brushRadius); + + if (this.pointer.down || pressed) gl.uniform4f(mouseLoc, mx, my, mpx, mpy); + else gl.uniform4f(mouseLoc, -mx, -my, -mpx, -mpy); + + const PASSES = 3; + for (let i = 0; i < PASSES; i++) { + gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo[this.swap]); + gl.viewport(0, 0, this.bufferWidth, this.bufferHeight); + + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + gl.uniform1i(frameLoc, this.frames * PASSES + i); + + gl.uniform1i(texLoc, 0); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.tex[1 - this.swap]); + + gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); + + this.swap = 1 - this.swap; + } + + this.frames++; + } + + private jfaPass() { + const gl = this.gl; + const JFA_PASSES = 5; + + gl.useProgram(this.jfaShadowProgram); + + const resLoc = gl.getUniformLocation(this.jfaShadowProgram, 'resolution'); + const texLoc = gl.getUniformLocation(this.jfaShadowProgram, 'texR'); + const texGLoc = gl.getUniformLocation(this.jfaShadowProgram, 'texG'); + const texBLoc = gl.getUniformLocation(this.jfaShadowProgram, 'texB'); + const stepSizeLoc = gl.getUniformLocation(this.jfaShadowProgram, 'stepSize'); + const passCountLoc = gl.getUniformLocation(this.jfaShadowProgram, 'passCount'); + const passIdxLoc = gl.getUniformLocation(this.jfaShadowProgram, 'passIndex'); + + gl.uniform2f(resLoc, this.bufferWidth, this.bufferHeight); + gl.uniform1i(texLoc, 0); + gl.uniform1i(texGLoc, 1); + gl.uniform1i(texBLoc, 2); + gl.uniform1i(passCountLoc, JFA_PASSES); + + for (let i = 0; i < JFA_PASSES; i++) { + gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFbo[this.shadowSwap]); + gl.viewport(0, 0, this.bufferWidth, this.bufferHeight); + + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + const stepSize = Math.pow(2, JFA_PASSES - i - 1); + + gl.uniform1f(stepSizeLoc, stepSize); + gl.uniform1i(passIdxLoc, i); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.shadowTexR[1 - this.shadowSwap]); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.shadowTexG[1 - this.shadowSwap]); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.shadowTexB[1 - this.shadowSwap]); + + gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); + + this.shadowSwap = 1 - this.shadowSwap; + } + } + + private shadowPass() { + const gl = this.gl; + this.shadowSwap = 0; + + gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFbo[this.shadowSwap]); + gl.viewport(0, 0, this.bufferWidth, this.bufferHeight); + + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + gl.useProgram(this.jfaInitProgram); + gl.bindVertexArray(this.vao); + + const resLoc = gl.getUniformLocation(this.jfaInitProgram, 'resolution'); + const texLoc = gl.getUniformLocation(this.jfaInitProgram, 'dataTex'); + + gl.uniform2f(resLoc, this.bufferWidth, this.bufferHeight); + + gl.uniform1i(texLoc, 0); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.tex[this.swap]); + + gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); + + this.shadowSwap = 1 - this.shadowSwap; + } + + private processResize() { + const gl = this.gl; + gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); + + this.bufferWidth = Math.ceil(gl.canvas.width / this.PIXELS_PER_PARTICLE); + this.bufferHeight = Math.ceil(gl.canvas.height / this.PIXELS_PER_PARTICLE); + + // Update collision texture size + gl.bindTexture(gl.TEXTURE_2D, this.collisionTex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, this.bufferWidth, this.bufferHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + + // Re-render collision data after resize + this.handleShapeTransform(); + + for (let i = 0; i < 2; i++) { + gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo[i]); + + const newTex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, newTex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, this.bufferWidth, this.bufferHeight, 0, gl.RGBA, gl.FLOAT, null); + + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + 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.bindTexture(gl.TEXTURE_2D, newTex); + gl.copyTexSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 0, 0, this.bufferWidth, this.bufferHeight); + + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, newTex, 0); + + gl.deleteTexture(this.tex[i]); + if (!this.tex[i]) { + throw new Error('Failed to create texture1'); + } + if (!newTex) { + throw new Error('Failed to create texture2'); + } + this.tex[i] = newTex; + } + + for (let i = 0; i < 2; i++) { + gl.bindTexture(gl.TEXTURE_2D, this.shadowTexR[i]); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, this.bufferWidth, this.bufferHeight, 0, gl.RGBA, gl.FLOAT, null); + + gl.bindTexture(gl.TEXTURE_2D, this.shadowTexG[i]); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, this.bufferWidth, this.bufferHeight, 0, gl.RGBA, gl.FLOAT, null); + + gl.bindTexture(gl.TEXTURE_2D, this.shadowTexB[i]); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, this.bufferWidth, this.bufferHeight, 0, gl.RGBA, gl.FLOAT, null); + } + } + + private createProgramFromStrings({ + vertex, + fragment, + }: { + vertex: string; + fragment: string; + }): WebGLProgram | undefined { + const vertexShader = WebGLUtils.createShader(this.gl, this.gl.VERTEX_SHADER, vertex); + const fragmentShader = WebGLUtils.createShader(this.gl, this.gl.FRAGMENT_SHADER, fragment); + + if (!vertexShader || !fragmentShader) { + console.error('Failed to create shaders'); + return undefined; + } + + return WebGLUtils.createProgram(this.gl, vertexShader, fragmentShader); + } + + private collectShapeData() { + const positions: number[] = []; + const indices: number[] = []; + let vertexOffset = 0; + + this.shapes.forEach((shape) => { + const rect = shape.getTransformDOMRect(); + if (!rect) return; + + // Get the transformed vertices in parent space + const transformedPoints = rect.vertices().map((point) => rect.toParentSpace(point)); + + // Convert the transformed points to buffer coordinates + const bufferPoints = transformedPoints.map((point) => this.convertToBufferCoordinates(point.x, point.y)); + + // Add vertices + bufferPoints.forEach((point) => { + positions.push(point.x, point.y); + }); + + // Add indices for two triangles + indices.push(vertexOffset, vertexOffset + 1, vertexOffset + 2, vertexOffset, vertexOffset + 2, vertexOffset + 3); + + vertexOffset += 4; + }); + + const gl = this.gl; + + // Update buffers with new data + gl.bindBuffer(gl.ARRAY_BUFFER, this.shapePositionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.DYNAMIC_DRAW); + + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.shapeIndexBuffer); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.DYNAMIC_DRAW); + + this.shapeIndexCount = indices.length; + } + + private convertToBufferCoordinates(x: number, y: number) { + return { + x: (x / this.clientWidth) * 2 - 1, + y: -((y / this.clientHeight) * 2 - 1), // Flip Y coordinate + }; + } + + private updateCollisionTexture() { + const gl = this.gl; + + gl.bindFramebuffer(gl.FRAMEBUFFER, this.collisionFbo); + gl.viewport(0, 0, this.bufferWidth, this.bufferHeight); + + // Clear with transparent black (no collision) + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + // Disable depth testing and blending + gl.disable(gl.DEPTH_TEST); + gl.disable(gl.BLEND); + + // Use collision shader program + gl.useProgram(this.collisionProgram); + gl.bindVertexArray(this.shapeVao); + + // Draw all shapes + if (this.shapeIndexCount > 0) { + gl.drawElements(gl.TRIANGLES, this.shapeIndexCount, gl.UNSIGNED_SHORT, 0); + } + + // Cleanup + gl.bindVertexArray(null); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + private handleShapeTransform = () => { + // Recollect and update all shape data when any shape changes + // TODO: do this more piecemeal + this.collectShapeData(); + this.updateCollisionTexture(); + }; +} diff --git a/src/standalone/folk-sand.ts b/src/standalone/folk-sand.ts new file mode 100644 index 0000000..e2d466a --- /dev/null +++ b/src/standalone/folk-sand.ts @@ -0,0 +1,5 @@ +import { FolkSand } from '../folk-sand'; + +FolkSand.define(); + +export { FolkSand }; diff --git a/tsconfig.json b/tsconfig.json index 85a65a2..d581ae5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"], "types": ["@webgpu/types", "@types/node", "bun-types"] }, - "include": ["src/**/*.ts", "demo/**/*.ts", "vite.config.ts"] + "include": ["src/**/*.ts", "demo/**/*.ts", "vite.config.ts", "globals.d.ts"] }