UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

472 lines (425 loc) 19.1 kB
import { AddEquation, CustomBlending, GLSL3, OneFactor, OneMinusSrcAlphaFactor, RawShaderMaterial, Vector3 } from "three"; /* * * For ray projection using projection matrix : https://encreative.blogspot.com/2019/05/computing-ray-origin-and-direction-from.html */ const shader_vx = ` in vec2 uv; in vec3 position; out vec2 vUv; // View-space position of this vertex. Interpolated per-fragment for the // tangent-space parallax computation in the fragment shader. out vec3 vViewPos; // The 4 atlas frames at the corners of the current grid cell are chosen // once per-impostor and forwarded as flat varyings so every fragment // samples the same set (without this, a perspective camera could flip // the chosen frames across the card and produce visible seams). // // We use bilinear interpolation over a full 2x2 cell, not a triangle // interpolation over 3 corners. Triangle interpolation has a // C1-discontinuous "diagonal switch" where one of the three contributing // frames swaps between (gridFloor+(1,0)) and (gridFloor+(0,1)); even // though the swap happens at zero weight (so the result is C0), the // weight DERIVATIVE jumps, which the eye perceives as a snap inside the // cell. Bilinear has no such switch — all 4 corners always contribute // with weights that are smooth products of fract(grid). flat out vec2 vGridFloor; flat out vec4 vWeights; // (w00, w10, w01, w11) // Card's TBN basis in view space. The card is oriented so its NORMAL // points along the weighted blend of the 4 nearest baked view // directions, and TANGENT/BINORMAL line up with the *blended* bake // camera's right/up. Used by the fragment shader to transform the // view-space view-dir into the card's tangent frame. flat out vec3 vTangent; flat out vec3 vBinormal; flat out vec3 vNormal; // Per-frame card-UV -> texture-UV rotation matrices, one per cell // corner. Each frame was baked with its own camera right/up (from // three.js Camera.lookAt with up=(0,1,0)); near the octahedral pole // those right-axes can swing 90° or more between adjacent frames. // These matrices rotate the sample-UV into each frame's bake-camera // basis so the four corners line up consistently when blended. flat out vec4 vFrameXform00; flat out vec4 vFrameXform10; flat out vec4 vFrameXform01; flat out vec4 vFrameXform11; uniform mat4 modelViewMatrix; uniform mat4 projectionMatrix; uniform vec3 uOffset; uniform float uRadius; uniform float uFrames; uniform bool uIsFullSphere; // ----- direction -> octahedral grid coord (range -1..+1) ----- vec2 VecToSphereOct(vec3 pivotToCamera) { vec3 octant = sign(pivotToCamera); float sum = dot(pivotToCamera, octant); vec3 octahedron = pivotToCamera / sum; if (octahedron.y < 0.0) { vec3 absolute = abs(octahedron); octahedron.xz = octant.xz * vec2(1.0 - absolute.z, 1.0 - absolute.x); } return octahedron.xz; } vec2 VecToHemiSphereOct(vec3 v) { v.y = max(v.y, 0.001); v = normalize(v); vec3 octant = sign(v); float sum = dot(v, octant); vec3 octahedron = v / sum; return vec2( octahedron.x + octahedron.z, octahedron.z - octahedron.x ); } vec2 VectorToGrid(vec3 v) { if (uIsFullSphere) { return VecToSphereOct(v); } else { return VecToHemiSphereOct(v); } } // ----- octahedral grid coord (range 0..1) -> direction ----- // // Inverse of VectorToGrid (after the 0.5/2.0 remap). Given the // normalised position of a frame in the atlas, return the world-space // direction the bake camera was sitting at when it captured that frame. vec3 OctaSphereDec(vec2 coord) { coord = (coord - 0.5) * 2.0; vec3 p = vec3(coord.x, 0.0, coord.y); vec2 a = abs(p.xz); p.y = 1.0 - a.x - a.y; if (p.y < 0.0) { p.xz = sign(p.xz) * vec2(1.0 - a.y, 1.0 - a.x); } return p; } vec3 OctaHemiSphereDec(vec2 coord) { vec3 p = vec3(coord.x - coord.y, 0.0, -1.0 + coord.x + coord.y); vec2 a = abs(p.xz); p.y = 1.0 - a.x - a.y; return p; } vec3 GridToVector(vec2 coord) { if (uIsFullSphere) { return OctaSphereDec(coord); } else { return OctaHemiSphereDec(coord); } } // Frame index (gridFloor + corner offset) -> baked view direction. // Frame coords at the grid border can sit slightly outside the // [0, uFrames-1] range (e.g. gridFloor + (1,1) at the corner); clamp so // we don't feed invalid UVs to the octahedral decoder. vec3 FrameToRay(vec2 frame, vec2 framesMinusOne) { vec2 f = clamp(frame / framesMinusOne, 0.0, 1.0); return normalize(GridToVector(f)); } // Build the 2x2 matrix that maps card-centred UV (vUv - 0.5) into the // texture-centred UV of a single baked frame. The matrix is just the // (tangent_OS, binormal_OS) basis expressed in the frame's own // (bake_right, bake_up) basis — i.e. how a step along the card's local // axes shows up in the texture the frame was rendered into. // // bake_right/bake_up follow three.js Camera.lookAt with up=(0,1,0). // We mirror its polar nudge so our shader basis matches what the // baker actually used when D_frame is parallel to up. vec4 ComputeFrameXform(vec3 D_frame, vec3 tangent_OS, vec3 binormal_OS) { vec3 D = abs(D_frame.y) > 0.99999 ? normalize(D_frame + vec3(0.0, 0.0, 0.0001)) : D_frame; vec3 bake_right = normalize(cross(vec3(0.0, 1.0, 0.0), D)); vec3 bake_up = cross(D, bake_right); return vec4( dot(tangent_OS, bake_right), dot(binormal_OS, bake_right), dot(tangent_OS, bake_up), dot(binormal_OS, bake_up) ); } // Bilinear weights over the 4 corners of the current cell, in the // order (w00, w10, w01, w11) matching gridFloor + (0,0)/(1,0)/(0,1)/(1,1). vec4 BilinearWeights(vec2 frac_uv) { vec2 omuv = vec2(1.0) - frac_uv; return vec4( omuv.x * omuv.y, // w00 frac_uv.x * omuv.y, // w10 omuv.x * frac_uv.y, // w01 frac_uv.x * frac_uv.y // w11 ); } void main() { vUv = uv; // 1. Octahedral atlas lookup. // cameraPos_OS is the camera position in the impostor's local // space — the same coordinate frame the meshes were in during // the bake. The encoded direction is what picks which atlas // frames we'll sample. vec3 cameraPos_OS = (inverse(modelViewMatrix) * vec4(0.0, 0.0, 0.0, 1.0)).xyz; vec3 pivotToCameraRay = normalize(cameraPos_OS); vec2 framesMinusOne = vec2(uFrames - 1.0); vec2 octahedral_uv = clamp(VectorToGrid(pivotToCameraRay) * 0.5 + 0.5, 0.0, 1.0); vec2 grid = octahedral_uv * framesMinusOne; // Clamp gridFloor so the +1 corners never reference past the last // valid frame index when grid is at the upper edge. vec2 gridFloor = min(floor(grid), framesMinusOne - 1.0); vec4 weights = BilinearWeights(grid - gridFloor); vGridFloor = gridFloor; vWeights = weights; // 2. Decode each of the 4 cell-corner frame indices back into its // bake-time view direction, then blend with the bilinear weights. // projectedRay is the "effective" baked view direction the card // is showing — the direction the depth and colour textures we're // blending were captured along. vec3 ray00 = FrameToRay(gridFloor + vec2(0.0, 0.0), framesMinusOne); vec3 ray10 = FrameToRay(gridFloor + vec2(1.0, 0.0), framesMinusOne); vec3 ray01 = FrameToRay(gridFloor + vec2(0.0, 1.0), framesMinusOne); vec3 ray11 = FrameToRay(gridFloor + vec2(1.0, 1.0), framesMinusOne); vec3 projectedRay = normalize( ray00 * weights.x + ray10 * weights.y + ray01 * weights.z + ray11 * weights.w ); // 3. Build the card's TBN in object-local space. // NORMAL = projectedRay (the effective bake direction). // TANGENT = cross(up, NORMAL) — matches three.js Camera.lookAt // (which is what the baker used), so position.x of // the cutout shape lines up with the texture's X. // BINORMAL = cross(NORMAL, TANGENT) — matches the bake camera's // up axis, so position.y lines up with texture Y. // // The bake used world-up (0,1,0). The bake's world frame IS our // object-local frame (the meshes lived there during the bake), // so we use object-local (0,1,0) here too. The fallback covers // the degenerate pole case where NORMAL is parallel to up. vec3 normal_OS = projectedRay; vec3 up_OS = abs(normal_OS.y) > 0.999 ? vec3(0.0, 0.0, -1.0) : vec3(0.0, 1.0, 0.0); vec3 tangent_OS = normalize(cross(up_OS, normal_OS)); vec3 binormal_OS = cross(normal_OS, tangent_OS); // 4. Position the card vertex in object-local space. // position.xy is in [-0.5, 0.5] (the centred cutout shape), so // multiplying by card_diameter places it on a card whose half- // width and half-height are uRadius — tangentially spanning the // bounding sphere. uOffset is the bounding sphere centre in // object-local space (captured at bake time). float card_diameter = uRadius * 2.0; vec3 pos_OS = uOffset + position.x * card_diameter * tangent_OS + position.y * card_diameter * binormal_OS; vec4 mvPosition = modelViewMatrix * vec4(pos_OS, 1.0); vViewPos = mvPosition.xyz; gl_Position = projectionMatrix * mvPosition; // 5. TBN in view space — what the fragment shader needs to convert // its view-space view-dir into tangent space. For typical // impostor transforms (rigid + uniform scale) mat3(modelView) is // orthogonal up to a uniform scalar, so renormalising after the // multiply is sufficient; non-uniform scale would require the // inverse-transpose. mat3 m3 = mat3(modelViewMatrix); vTangent = normalize(m3 * tangent_OS); vBinormal = normalize(m3 * binormal_OS); vNormal = normalize(m3 * normal_OS); // 6. Per-frame UV rotation matrices. ComputeFrameXform handles the // polar-singularity nudge in three.js's lookAt internally. vFrameXform00 = ComputeFrameXform(ray00, tangent_OS, binormal_OS); vFrameXform10 = ComputeFrameXform(ray10, tangent_OS, binormal_OS); vFrameXform01 = ComputeFrameXform(ray01, tangent_OS, binormal_OS); vFrameXform11 = ComputeFrameXform(ray11, tangent_OS, binormal_OS); } `; const shader_fg = ` precision highp float; precision highp int; in vec2 vUv; in vec3 vViewPos; // Cell base + bilinear weights for the 4 corners (w00, w10, w01, w11), // computed per-impostor in the vertex shader. flat = no interpolation. flat in vec2 vGridFloor; flat in vec4 vWeights; // Card's TBN in view space (perpendicular to the effective bake // direction). Used to express the view direction in the card's tangent // frame for parallax. flat in vec3 vTangent; flat in vec3 vBinormal; flat in vec3 vNormal; // Per-frame 2x2 rotation matrices (a, b, c, d) that map card-centred // UV into each frame's bake-camera basis. Each cell corner has its own. flat in vec4 vFrameXform00; flat in vec4 vFrameXform10; flat in vec4 vFrameXform01; flat in vec4 vFrameXform11; out vec4 color_out; uniform sampler2D tBase; uniform sampler2D tGeometry; uniform float uFrames; uniform float uDepthScale; // Apply a per-frame 2x2 rotation matrix to a card UV, rotating around // the texture centre (0.5, 0.5). xform = (a, b, c, d) packs row-major // [a b ; c d]. vec2 apply_frame_xform(vec2 card_uv, vec4 xform) { vec2 c = card_uv - 0.5; return vec2( xform.x * c.x + xform.y * c.y, xform.z * c.x + xform.w * c.y ) + 0.5; } // Sample the same card-UV from the 4 cell-corner atlas frames, applying // each frame's own UV rotation so the textures land in a consistent // orientation, then blend with the bilinear weights. Used for both // depth and colour. vec4 blend_4_frames( sampler2D tex, vec2 card_uv, vec2 gridFloor, vec4 w, vec4 x00, vec4 x10, vec4 x01, vec4 x11 ) { vec2 frame_size = vec2(1.0 / uFrames); // Clamp inside the unit square AFTER the per-frame rotation: at // sharp angles the rotation can push a corner UV outside [0,1] and // bleed into the neighbouring atlas tile (which is a completely // different bake direction). Clamping keeps the sample inside the // frame it's supposed to belong to. vec2 uv00 = clamp(apply_frame_xform(card_uv, x00), 0.0, 1.0); vec2 uv10 = clamp(apply_frame_xform(card_uv, x10), 0.0, 1.0); vec2 uv01 = clamp(apply_frame_xform(card_uv, x01), 0.0, 1.0); vec2 uv11 = clamp(apply_frame_xform(card_uv, x11), 0.0, 1.0); vec4 s00 = texture(tex, (gridFloor + vec2(0.0, 0.0) + uv00) * frame_size); vec4 s10 = texture(tex, (gridFloor + vec2(1.0, 0.0) + uv10) * frame_size); vec4 s01 = texture(tex, (gridFloor + vec2(0.0, 1.0) + uv01) * frame_size); vec4 s11 = texture(tex, (gridFloor + vec2(1.0, 1.0) + uv11) * frame_size); return s00 * w.x + s10 * w.y + s01 * w.z + s11 * w.w; } void main() { // View direction in the card's tangent space. The card is oriented // perpendicular to the effective bake direction, not view-aligned, // so we project the view-space view-dir onto the (T, B, N) basis we // built in the vertex shader. vec3 view_dir_view = normalize(-vViewPos); vec3 view_dir = vec3( dot(vTangent, view_dir_view), dot(vBinormal, view_dir_view), dot(vNormal, view_dir_view) ); // One-step approximate parallax: sample the blended depth at the // un-corrected UV, then shift the UV by the height that depth // implies. // // Our bake writes depth = 1 at the near plane (front of the bounding // sphere, closest to the bake camera), depth = 0 at the far plane, // depth = 0.5 at the bounding sphere centre — i.e. the card plane. // So (depth - 0.5) is the signed height of the surface above the // card plane, in units where ±0.5 = ±radius. The card spans 2*radius // mapped to UV [0,1], so that height is already in card-UV units. // // Standard parallax-mapping formula: shift base_uv by V.xy / V.z * h, // where V is the view direction in tangent space and h is the // surface height above the card plane. vec2 base_uv = vUv; float depth = blend_4_frames( tGeometry, base_uv, vGridFloor, vWeights, vFrameXform00, vFrameXform10, vFrameXform01, vFrameXform11 ).a; base_uv += (view_dir.xy / view_dir.z) * (depth - 0.5) * uDepthScale; // Keep the parallax-shifted sample inside the current frame's tile. // Without this, large shifts at oblique angles bleed colour from the // neighbouring atlas frame (a completely different bake direction). base_uv = clamp(base_uv, 0.0, 1.0); vec4 texel_color = blend_4_frames( tBase, base_uv, vGridFloor, vWeights, vFrameXform00, vFrameXform10, vFrameXform01, vFrameXform11 ); if (texel_color.a <= 0.5) { discard; } color_out = texel_color; } `; export class ImpostorShaderV0 extends RawShaderMaterial { constructor() { super({ fragmentShader: shader_fg, vertexShader: shader_vx, uniforms: { /** * RGB + Alpha */ tBase: { value: null }, /** * Normal+Depth */ tGeometry: { value: null }, /** * Material properties: Occlusion, Roughness, Metalness * Alpha unused */ tMaterial: { value: null }, /** * Number of frames */ uFrames: { value: 0 }, /** * Radius of bounding sphere of the impostor */ uRadius: { value: 0 }, /** * Impostor offset */ uOffset: { value: new Vector3(0, 0, 0) }, uIsFullSphere: { value: false }, /** * Strength of the tangent-space parallax offset. The * geometrically-correct value for a true sphere surface is * about 1.0, but in practice 0.3–0.5 produces a more * believable result because the bake only contains the * silhouette and front-facing geometry — strong parallax * starts revealing missing/occluded parts at oblique angles. * Range: [0, 1]. */ uDepthScale:{ value: 0.5 } }, glslVersion: GLSL3 }); // Save some effort by disabling blending this.blending = CustomBlending; this.blendEquation = AddEquation; this.blendSrc = OneFactor; this.blendDst = OneMinusSrcAlphaFactor; } }