Codrops 원본
Building a Mouse-Following Square Lens Effect with Three.js and GLSL
포인터 · MIT
갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.
SOURCE FILES
원본 코드 읽기
수집한 원본 소스와 실행 안내를 함께 제공합니다.
astro.config.mjs
// @ts-check
import { defineConfig } from "astro/config";
import path from "path";
import glsl from "vite-plugin-glsl";
// https://astro.build/config
export default defineConfig({
// base: "/webgl/",
build: {
assets: "assets/js",
},
server: {
host: true,
},
site: "https://www.yukiloz7.com/",
vite: {
build: {
rollupOptions: {
output: {
entryFileNames: "assets/js/[name].[hash].js",
chunkFileNames: "assets/js/[name].[hash].js",
assetFileNames: (assetInfo) => {
const name = assetInfo.name ?? "";
if (/\.css$/i.test(name)) {
return "assets/css/[name].[hash][extname]";
}
if (/\.(avif|webp|png|jpe?g|gif|svg)$/i.test(name)) {
return "assets/img/[name].[hash][extname]";
}
return "assets/[name].[hash][extname]";
},
},
},
},
plugins: [
glsl({
include: ["**/*.glsl", "**/*.vert", "**/*.frag"],
warnDuplicatedImports: true,
removeDuplicatedImports: true,
minify: false,
watch: true,
}),
],
resolve: {
alias: {
"@": path.join(process.cwd(), "src"),
"@scripts": path.join(process.cwd(), "src/scripts"),
"@styles": path.join(process.cwd(), "src/styles"),
"@layouts": path.join(process.cwd(), "src/layouts"),
},
},
},
});
eslint.config.mjs
import js from "@eslint/js";
import astro from "eslint-plugin-astro";
import tseslint from "typescript-eslint";
export default [
{
ignores: ["dist/**", "node_modules/**", ".astro/**"],
},
js.configs.recommended,
...tseslint.configs.recommended,
...astro.configs["flat/recommended"],
];
함께 쓰는 파일 26개 보기
src/env.d.ts
/// <reference types="astro/client" />
declare module "*.vert" {
const shader: string
export default shader
}
declare module "*.frag" {
const shader: string
export default shader
}
declare module "*.glsl" {
const shader: string
export default shader
}
src/scripts/index.ts
import Webgl from "./webgl/Webgl"
const revealPage = (): void => {
document.body.classList.remove("loading")
}
const boot = async (): Promise<void> => {
const root = document.querySelector("[data-page]")
if (!root) {
revealPage()
return
}
const webgl = new Webgl()
try {
webgl.init()
const isReady = await webgl.enter()
if (isReady) {
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve())
})
document.body.classList.add("webgl-ready")
}
} catch (error) {
console.error("Failed to initialize the WebGL demo.", error)
} finally {
revealPage()
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true })
} else {
boot()
}
src/scripts/webgl/Webgl.ts
import GUI from "lil-gui"
import Stage from "./stage/Stage"
import Mesh, { type ShaderParams } from "./mesh/Mesh"
interface SelectorNames {
root: string
canvas: string
mesh: string
}
export default class Webgl {
private selectorNames: SelectorNames
private shaderParams: ShaderParams
private $root: HTMLElement | null
private $canvas: HTMLCanvasElement | null
private stage: Stage | null
private mesh: Mesh | null
private gui: GUI | null
private rafId: number | null
constructor() {
this.selectorNames = {
root: "[data-page]",
canvas: "[data-canvas]",
mesh: "[data-mesh]"
}
this.shaderParams = {
squareSize: 0.4,
lensDistortion: 1.5,
rgbShiftR: 0.01,
rgbShiftG: 0,
rgbShiftB: -0.01,
waveFrequency: 10,
waveStrength: 0.01,
waveSpeed: 1,
randomFrequency: 1,
randomStrength: 0.02,
randomSpeed: 0.2,
pointerEase: 0.1
}
this.$root = null
this.$canvas = null
this.stage = null
this.mesh = null
this.gui = null
this.rafId = null
}
public init(): void {
this.setSelector()
this.setStage()
this.addEventListeners()
this.raf()
}
public destroy(): void {
this.caf()
this.removeEventListeners()
this.leave()
if (this.stage) {
this.stage.destroy()
}
}
public async enter(): Promise<boolean> {
const $target = this.$root?.querySelector<HTMLElement>(
this.selectorNames.mesh
)
const scene = this.stage?.getScene()
if (!$target || !scene) return false
this.mesh = new Mesh({
scene,
$target,
shaderParams: this.shaderParams
})
await this.mesh.init()
this.setGUI()
return true
}
public leave(): void {
this.destroyGUI()
if (this.mesh) {
this.mesh.destroy()
this.mesh = null
}
}
private setSelector(): void {
this.$root = document.querySelector(this.selectorNames.root)
this.$canvas = document.querySelector(this.selectorNames.canvas)
}
private setStage(): void {
this.stage = new Stage({
$canvas: this.$canvas
})
this.stage.init()
}
private setGUI(): void {
this.destroyGUI()
this.gui = new GUI({
title: "Square Texture Effect"
})
this.gui.onChange(this.updateShaderParams)
const squareFolder = this.gui.addFolder("Square")
const rgbShiftFolder = this.gui.addFolder("RGB Shift")
const waveFolder = this.gui.addFolder("Wave")
const randomFolder = this.gui.addFolder("Random")
const pointerFolder = this.gui.addFolder("Pointer")
squareFolder
.add(this.shaderParams, "squareSize", 0, 5, 0.01)
.name("Square Size")
squareFolder
.add(this.shaderParams, "lensDistortion", -5, 5, 0.01)
.name("Lens Distortion")
rgbShiftFolder
.add(this.shaderParams, "rgbShiftR", -0.05, 0.05, 0.001)
.name("Red Shift")
rgbShiftFolder
.add(this.shaderParams, "rgbShiftG", -0.05, 0.05, 0.001)
.name("Green Shift")
rgbShiftFolder
.add(this.shaderParams, "rgbShiftB", -0.05, 0.05, 0.001)
.name("Blue Shift")
waveFolder
.add(this.shaderParams, "waveFrequency", 0, 200, 1)
.name("Wave Frequency")
waveFolder
.add(this.shaderParams, "waveStrength", 0, 0.1, 0.001)
.name("Wave Strength")
waveFolder
.add(this.shaderParams, "waveSpeed", 0, 5, 0.01)
.name("Wave Speed")
randomFolder
.add(this.shaderParams, "randomFrequency", 0.1, 20, 0.1)
.name("Random Frequency")
randomFolder
.add(this.shaderParams, "randomStrength", 0, 0.1, 0.001)
.name("Random Strength")
randomFolder
.add(this.shaderParams, "randomSpeed", 0, 2, 0.01)
.name("Random Speed")
pointerFolder
.add(this.shaderParams, "pointerEase", 0.01, 1, 0.01)
.name("Pointer Ease")
}
private destroyGUI(): void {
if (!this.gui) return
this.gui.destroy()
this.gui = null
}
private addEventListeners(): void {
window.addEventListener("pointermove", this.onPointerMove)
window.addEventListener("resize", this.onResize)
}
private removeEventListeners(): void {
window.removeEventListener("pointermove", this.onPointerMove)
window.removeEventListener("resize", this.onResize)
}
private onPointerMove = (event: PointerEvent): void => {
if (this.mesh) {
this.mesh.onPointerMove(event)
}
}
private onResize = (): void => {
if (this.stage) {
this.stage.onResize()
}
if (this.mesh) {
this.mesh.onResize()
}
}
private updateShaderParams = (): void => {
if (this.mesh) {
this.mesh.setShaderParams(this.shaderParams)
}
}
private raf(): void {
this.rafId = requestAnimationFrame(this.onRaf)
}
private caf(): void {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
}
private onRaf = (): void => {
this.render()
this.rafId = requestAnimationFrame(this.onRaf)
}
private render(): void {
if (this.mesh) {
this.mesh.render()
}
if (this.stage) {
this.stage.render()
}
}
}
src/scripts/webgl/glsl/chunks/ccLens.glsl
// ---------------------------------------------------------------------------
// Applies a CC Lens-style radial distortion to UV coordinates.
// ---------------------------------------------------------------------------
float getCCLensScale(float distortion, float radius2) {
if (distortion >= 0.0) {
return 1.0 + distortion * radius2;
}
return 1.0 / (1.0 - distortion * radius2);
}
vec2 getCCLensUv(vec2 uv, vec2 resolution, float distortion) {
vec2 centeredUv = uv - 0.5;
vec2 aspectScale = vec2(resolution.x / resolution.y, 1.0);
vec2 centeredPosition = centeredUv * aspectScale;
float radius2 = dot(centeredPosition, centeredPosition);
float lensScale = getCCLensScale(distortion, radius2);
vec2 distortedPosition = centeredPosition * lensScale;
vec2 distortedCenteredUv = distortedPosition / aspectScale;
vec2 distortedUv = distortedCenteredUv + 0.5;
vec2 distortionOffset = distortedUv - uv;
return uv - distortionOffset;
}
src/scripts/webgl/glsl/chunks/coverUv.glsl
// ---------------------------------------------------------------------------
// Make the texture UVs behave like CSS's `background-size: cover`.
// ---------------------------------------------------------------------------
vec2 getCoverUv(vec2 uv, vec2 meshSize, vec2 textureSize) {
vec2 meshRatio = vec2(meshSize.x / meshSize.y, meshSize.y / meshSize.x);
vec2 textureRatio = vec2(textureSize.x / textureSize.y, textureSize.y / textureSize.x);
vec2 resolutionRatio = vec2(
min(meshRatio.x / textureRatio.x, 1.0),
min(meshRatio.y / textureRatio.y, 1.0)
);
return (uv - 0.5) * resolutionRatio + 0.5;
}
src/scripts/webgl/glsl/chunks/random3.glsl
// ---------------------------------------------------------------------------
// Generates a pseudo-random vec3 from a 3D input coordinate.
// ---------------------------------------------------------------------------
// <www.shadertoy.com/view/XsX3zB>
// by Nikita Miropolskiy
vec3 random3(vec3 c) {
float j = 4096.0 * sin(dot(c, vec3(17.0, 59.4, 15.0)));
vec3 r;
r.z = fract(512.0 * j);
j *= 0.125;
r.x = fract(512.0 * j);
j *= 0.125;
r.y = fract(512.0 * j);
return r - 0.5;
}
src/scripts/webgl/glsl/frag/frag.glsl
precision highp float;
uniform sampler2D u_texture1;
uniform sampler2D u_texture2;
uniform vec2 u_meshSize;
uniform vec2 u_textureSize1;
uniform vec2 u_textureSize2;
uniform vec2 u_mouse;
uniform float u_squareSize;
uniform float u_lensDistortion;
uniform float u_rgbShiftR;
uniform float u_rgbShiftG;
uniform float u_rgbShiftB;
uniform float u_waveFrequency;
uniform float u_waveStrength;
uniform float u_waveSpeed;
uniform float u_randomFrequency;
uniform float u_randomStrength;
uniform float u_randomSpeed;
uniform float u_time;
varying vec2 v_uv;
#include "../chunks/coverUv.glsl"
#include "../chunks/ccLens.glsl"
#include "../chunks/random3.glsl"
void main() {
vec2 texture2Uv = getCoverUv(v_uv, u_meshSize, u_textureSize2);
// Convert the square UV coordinates from the 0.0-to-1.0 range to the -1.0-to-1.0 range.
vec2 uvSquare = v_uv * 2.0 - 1.0;
// Offset the coordinates so the mouse position becomes the origin for the square test.
uvSquare -= u_mouse;
// Calculate an aspect-ratio correction factor for determining the square's bounds.
vec2 squareAspectScale = vec2(min((u_meshSize.y / u_meshSize.x), 1.0), min((u_meshSize.x / u_meshSize.y), 1.0));
// Correct the test coordinates so the mask remains square.
uvSquare /= squareAspectScale;
float wave = sin(
texture2Uv.y * u_waveFrequency
+ u_time * u_waveSpeed
) * u_waveStrength;
texture2Uv.y += wave;
texture2Uv += random3(
vec3(texture2Uv * u_randomFrequency, u_time * u_randomSpeed)
).x * u_randomStrength;
// Store half the square's side length so u_squareSize can control its size.
float squareHalfSize = u_squareSize;
// Calculate the square's left edge.
float left = -squareHalfSize;
// Calculate the square's right edge.
float right = squareHalfSize;
// Calculate the square's bottom edge.
float bottom = -squareHalfSize;
// Calculate the square's top edge.
float top = squareHalfSize;
// Return 1.0 when uvSquare is inside the square and 0.0 when it is outside.
float squareMask = step(left, uvSquare.x) * (1.0 - step(right, uvSquare.x)) * step(bottom, uvSquare.y) * (1.0 - step(top, uvSquare.y));
// Convert uvSquare to the 0.0-to-1.0 range for the CC Lens distortion.
vec2 squareUv = uvSquare / (squareHalfSize * 2.0) + 0.5;
// Apply the CC Lens distortion inside the square.
vec2 distortedSquareUv = getCCLensUv(squareUv, vec2(1.0), u_lensDistortion);
// Calculate the square-local offset from the difference before and after distortion.
vec2 squareLensOffset = distortedSquareUv - squareUv;
// squareLensOffset is relative to the square. Adding it directly to v_uv would treat it as
// relative to the entire mesh and make the distortion too large, so scale it by the square's
// half-size and aspect-ratio correction factor to convert it to a mesh-relative offset.
vec2 viewportLensOffset = squareLensOffset * squareHalfSize * squareAspectScale;
// Convert the coordinates inside the square to UV coordinates for u_texture1.
vec2 lensTexture1Uv = getCoverUv(v_uv + viewportLensOffset, u_meshSize, u_textureSize1);
// Use the square's center as the origin so the RGB shift grows stronger toward the outside.
vec2 rgbShiftDirection = (squareUv - 0.5) * 2.0;
// Sample each RGB channel with a different UV offset to create color separation.
float r = texture2D(u_texture1, lensTexture1Uv + rgbShiftDirection * u_rgbShiftR).r;
float g = texture2D(u_texture1, lensTexture1Uv + rgbShiftDirection * u_rgbShiftG).g;
float b = texture2D(u_texture1, lensTexture1Uv + rgbShiftDirection * u_rgbShiftB).b;
// Recombine the shifted u_texture1 channels to create the color inside the square.
vec4 insideColor = vec4(r, g, b, 1.0);
// Sample u_texture2 for the color outside the square.
vec4 outsideColor = texture2D(u_texture2, texture2Uv);
// Blend the inside and outside colors according to the square mask.
vec4 finalColor = mix(outsideColor, insideColor, squareMask);
gl_FragColor = finalColor;
}
src/scripts/webgl/glsl/frag/steps/01-display-color-image.glsl
precision highp float;
uniform sampler2D u_texture1;
uniform sampler2D u_texture2;
uniform vec2 u_meshSize;
uniform vec2 u_textureSize1;
uniform vec2 u_textureSize2;
varying vec2 v_uv;
#include "../chunks/coverUv.glsl"
void main() {
vec2 texture1Uv = getCoverUv(
v_uv,
u_meshSize,
u_textureSize1
);
vec2 texture2Uv = getCoverUv(
v_uv,
u_meshSize,
u_textureSize2
);
vec4 insideColor = texture2D(
u_texture1,
texture1Uv
);
vec4 outsideColor = texture2D(
u_texture2,
texture2Uv
);
gl_FragColor = insideColor;
}src/scripts/webgl/glsl/frag/steps/02-display-grayscale-image.glsl
precision highp float;
uniform sampler2D u_texture1;
uniform sampler2D u_texture2;
uniform vec2 u_meshSize;
uniform vec2 u_textureSize1;
uniform vec2 u_textureSize2;
varying vec2 v_uv;
#include "../chunks/coverUv.glsl"
void main() {
vec2 texture1Uv = getCoverUv(
v_uv,
u_meshSize,
u_textureSize1
);
vec2 texture2Uv = getCoverUv(
v_uv,
u_meshSize,
u_textureSize2
);
vec4 insideColor = texture2D(
u_texture1,
texture1Uv
);
vec4 outsideColor = texture2D(
u_texture2,
texture2Uv
);
gl_FragColor = outsideColor;
}src/scripts/webgl/glsl/frag/steps/03-create-square-mask.glsl
precision highp float;
uniform sampler2D u_texture1;
uniform sampler2D u_texture2;
uniform vec2 u_meshSize;
uniform vec2 u_textureSize1;
uniform vec2 u_textureSize2;
uniform float u_squareSize;
varying vec2 v_uv;
#include "../chunks/coverUv.glsl"
void main() {
vec2 texture1Uv = getCoverUv(
v_uv,
u_meshSize,
u_textureSize1
);
vec2 texture2Uv = getCoverUv(
v_uv,
u_meshSize,
u_textureSize2
);
vec4 insideColor = texture2D(
u_texture1,
texture1Uv
);
vec4 outsideColor = texture2D(
u_texture2,
texture2Uv
);
vec2 uvSquare = v_uv * 2.0 - 1.0;
float squareHalfSize = u_squareSize;
float left = -squareHalfSize;
float right = squareHalfSize;
float bottom = -squareHalfSize;
float top = squareHalfSize;
float squareMask =
step(left, uvSquare.x)
* (1.0 - step(right, uvSquare.x))
* step(bottom, uvSquare.y)
* (1.0 - step(top, uvSquare.y));
gl_FragColor = vec4(vec3(squareMask), 1.0);
}src/scripts/webgl/glsl/frag/steps/04-correct-mask-aspect-ratio.glsl
precision highp float;
uniform sampler2D u_texture1;
uniform sampler2D u_texture2;
uniform vec2 u_meshSize;
uniform vec2 u_textureSize1;
uniform vec2 u_textureSize2;
uniform float u_squareSize;
varying vec2 v_uv;
#include "../chunks/coverUv.glsl"
void main() {
vec2 texture1Uv = getCoverUv(
v_uv,
u_meshSize,
u_textureSize1
);
vec2 texture2Uv = getCoverUv(
v_uv,
u_meshSize,
u_textureSize2
);
vec4 insideColor = texture2D(
u_texture1,
texture1Uv
);
vec4 outsideColor = texture2D(
u_texture2,
texture2Uv
);
vec2 uvSquare = v_uv * 2.0 - 1.0;
vec2 squareAspectScale = vec2(
min(u_meshSize.y / u_meshSize.x, 1.0),
min(u_meshSize.x / u_meshSize.y, 1.0)
);
uvSquare /= squareAspectScale;
float squareHalfSize = u_squareSize;
float left = -squareHalfSize;
float right = squareHalfSize;
float bottom = -squareHalfSize;
float top = squareHalfSize;
float squareMask =
step(left, uvSquare.x)
* (1.0 - step(right, uvSquare.x))
* step(bottom, uvSquare.y)
* (1.0 - step(top, uvSquare.y));
gl_FragColor = vec4(vec3(squareMask), 1.0);
}src/scripts/webgl/glsl/frag/steps/05-composite-images.glsl
precision highp float;
uniform sampler2D u_texture1;
uniform sampler2D u_texture2;
uniform vec2 u_meshSize;
uniform vec2 u_textureSize1;
uniform vec2 u_textureSize2;
uniform float u_squareSize;
varying vec2 v_uv;
#include "../chunks/coverUv.glsl"
void main() {
vec2 texture1Uv = getCoverUv(
v_uv,
u_meshSize,
u_textureSize1
);
vec2 texture2Uv = getCoverUv(
v_uv,
u_meshSize,
u_textureSize2
);
vec2 uvSquare = v_uv * 2.0 - 1.0;
vec2 squareAspectScale = vec2(
min(u_meshSize.y / u_meshSize.x, 1.0),
min(u_meshSize.x / u_meshSize.y, 1.0)
);
uvSquare /= squareAspectScale;
float squareHalfSize = u_squareSize;
float left = -squareHalfSize;
float right = squareHalfSize;
float bottom = -squareHalfSize;
float top = squareHalfSize;
float squareMask =
step(left, uvSquare.x)
* (1.0 - step(right, uvSquare.x))
* step(bottom, uvSquare.y)
* (1.0 - step(top, uvSquare.y));
vec4 insideColor = texture2D(
u_texture1,
texture1Uv
);
vec4 outsideColor = texture2D(
u_texture2,
texture2Uv
);
vec4 finalColor = mix(
outsideColor,
insideColor,
squareMask
);
gl_FragColor = finalColor;
}src/scripts/webgl/glsl/frag/steps/06-apply-cc-lens-distortion.glsl
precision highp float;
uniform sampler2D u_texture1;
uniform sampler2D u_texture2;
uniform vec2 u_meshSize;
uniform vec2 u_textureSize1;
uniform vec2 u_textureSize2;
uniform float u_squareSize;
uniform float u_lensDistortion;
varying vec2 v_uv;
#include "../chunks/coverUv.glsl"
#include "../chunks/ccLens.glsl"
void main() {
vec2 texture2Uv = getCoverUv(
v_uv,
u_meshSize,
u_textureSize2
);
vec2 uvSquare = v_uv * 2.0 - 1.0;
vec2 squareAspectScale = vec2(
min(u_meshSize.y / u_meshSize.x, 1.0),
min(u_meshSize.x / u_meshSize.y, 1.0)
);
uvSquare /= squareAspectScale;
float squareHalfSize = u_squareSize;
float left = -squareHalfSize;
float right = squareHalfSize;
float bottom = -squareHalfSize;
float top = squareHalfSize;
float squareMask =
step(left, uvSquare.x)
* (1.0 - step(right, uvSquare.x))
* step(bottom, uvSquare.y)
* (1.0 - step(top, uvSquare.y));
vec2 squareUv =
uvSquare / (squareHalfSize * 2.0) + 0.5;
vec2 distortedSquareUv = getCCLensUv(
squareUv,
vec2(1.0),
u_lensDistortion
);
vec2 squareLensOffset =
distortedSquareUv - squareUv;
vec2 viewportLensOffset =
squareLensOffset
* squareHalfSize
* squareAspectScale;
vec2 lensTexture1Uv = getCoverUv(
v_uv + viewportLensOffset,
u_meshSize,
u_textureSize1
);
vec4 insideColor = texture2D(
u_texture1,
lensTexture1Uv
);
vec4 outsideColor = texture2D(
u_texture2,
texture2Uv
);
vec4 finalColor = mix(
outsideColor,
insideColor,
squareMask
);
gl_FragColor = finalColor;
}
src/scripts/webgl/glsl/frag/steps/07-apply-rgb-shift.glsl
precision highp float;
uniform sampler2D u_texture1;
uniform sampler2D u_texture2;
uniform vec2 u_meshSize;
uniform vec2 u_textureSize1;
uniform vec2 u_textureSize2;
uniform float u_squareSize;
uniform float u_lensDistortion;
uniform float u_rgbShiftR;
uniform float u_rgbShiftG;
uniform float u_rgbShiftB;
varying vec2 v_uv;
#include "../chunks/coverUv.glsl"
#include "../chunks/ccLens.glsl"
void main() {
vec2 texture2Uv = getCoverUv(
v_uv,
u_meshSize,
u_textureSize2
);
vec2 uvSquare = v_uv * 2.0 - 1.0;
vec2 squareAspectScale = vec2(
min(u_meshSize.y / u_meshSize.x, 1.0),
min(u_meshSize.x / u_meshSize.y, 1.0)
);
uvSquare /= squareAspectScale;
float squareHalfSize = u_squareSize;
float left = -squareHalfSize;
float right = squareHalfSize;
float bottom = -squareHalfSize;
float top = squareHalfSize;
float squareMask =
step(left, uvSquare.x)
* (1.0 - step(right, uvSquare.x))
* step(bottom, uvSquare.y)
* (1.0 - step(top, uvSquare.y));
vec2 squareUv =
uvSquare / (squareHalfSize * 2.0) + 0.5;
vec2 distortedSquareUv = getCCLensUv(
squareUv,
vec2(1.0),
u_lensDistortion
);
vec2 squareLensOffset =
distortedSquareUv - squareUv;
vec2 viewportLensOffset =
squareLensOffset
* squareHalfSize
* squareAspectScale;
vec2 lensTexture1Uv = getCoverUv(
v_uv + viewportLensOffset,
u_meshSize,
u_textureSize1
);
vec2 rgbShiftDirection =
(squareUv - 0.5) * 2.0;
float r = texture2D(
u_texture1,
lensTexture1Uv
+ rgbShiftDirection * u_rgbShiftR
).r;
float g = texture2D(
u_texture1,
lensTexture1Uv
+ rgbShiftDirection * u_rgbShiftG
).g;
float b = texture2D(
u_texture1,
lensTexture1Uv
+ rgbShiftDirection * u_rgbShiftB
).b;
vec4 insideColor = vec4(r, g, b, 1.0);
vec4 outsideColor = texture2D(
u_texture2,
texture2Uv
);
vec4 finalColor = mix(
outsideColor,
insideColor,
squareMask
);
gl_FragColor = finalColor;
}
src/scripts/webgl/glsl/frag/steps/08-follow-mouse.glsl
precision highp float;
uniform sampler2D u_texture1;
uniform sampler2D u_texture2;
uniform vec2 u_meshSize;
uniform vec2 u_textureSize1;
uniform vec2 u_textureSize2;
uniform vec2 u_mouse;
uniform float u_squareSize;
uniform float u_lensDistortion;
uniform float u_rgbShiftR;
uniform float u_rgbShiftG;
uniform float u_rgbShiftB;
varying vec2 v_uv;
#include "../chunks/coverUv.glsl"
#include "../chunks/ccLens.glsl"
void main() {
vec2 texture2Uv = getCoverUv(
v_uv,
u_meshSize,
u_textureSize2
);
vec2 uvSquare = v_uv * 2.0 - 1.0;
uvSquare -= u_mouse;
vec2 squareAspectScale = vec2(
min(u_meshSize.y / u_meshSize.x, 1.0),
min(u_meshSize.x / u_meshSize.y, 1.0)
);
uvSquare /= squareAspectScale;
float squareHalfSize = u_squareSize;
float left = -squareHalfSize;
float right = squareHalfSize;
float bottom = -squareHalfSize;
float top = squareHalfSize;
float squareMask =
step(left, uvSquare.x)
* (1.0 - step(right, uvSquare.x))
* step(bottom, uvSquare.y)
* (1.0 - step(top, uvSquare.y));
vec2 squareUv =
uvSquare / (squareHalfSize * 2.0) + 0.5;
vec2 distortedSquareUv = getCCLensUv(
squareUv,
vec2(1.0),
u_lensDistortion
);
vec2 squareLensOffset =
distortedSquareUv - squareUv;
vec2 viewportLensOffset =
squareLensOffset
* squareHalfSize
* squareAspectScale;
vec2 lensTexture1Uv = getCoverUv(
v_uv + viewportLensOffset,
u_meshSize,
u_textureSize1
);
vec2 rgbShiftDirection =
(squareUv - 0.5) * 2.0;
float r = texture2D(
u_texture1,
lensTexture1Uv
+ rgbShiftDirection * u_rgbShiftR
).r;
float g = texture2D(
u_texture1,
lensTexture1Uv
+ rgbShiftDirection * u_rgbShiftG
).g;
float b = texture2D(
u_texture1,
lensTexture1Uv
+ rgbShiftDirection * u_rgbShiftB
).b;
vec4 insideColor = vec4(r, g, b, 1.0);
vec4 outsideColor = texture2D(
u_texture2,
texture2Uv
);
vec4 finalColor = mix(
outsideColor,
insideColor,
squareMask
);
gl_FragColor = finalColor;
}
src/scripts/webgl/glsl/vert/vert.glsl
precision highp float;
attribute vec3 position;
attribute vec2 uv;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
varying vec2 v_uv;
void main() {
v_uv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
src/scripts/webgl/mesh/Mesh.ts
import {
TextureLoader,
PlaneGeometry,
RawShaderMaterial,
Mesh as ThreeMesh,
Texture,
Vector2
} from "three"
import type { IUniform, Scene } from "three"
import VertexShader from "../glsl/vert/vert.glsl"
import FragmentShader from "../glsl/frag/frag.glsl"
interface MeshOptions {
scene: Scene
$target: HTMLElement
shaderParams: ShaderParams
}
interface Uniforms {
[uniform: string]: IUniform<unknown>
u_texture1: IUniform<Texture | null>
u_texture2: IUniform<Texture | null>
u_meshSize: IUniform<Vector2>
u_textureSize1: IUniform<Vector2>
u_textureSize2: IUniform<Vector2>
u_mouse: IUniform<Vector2>
u_squareSize: IUniform<number>
u_lensDistortion: IUniform<number>
u_rgbShiftR: IUniform<number>
u_rgbShiftG: IUniform<number>
u_rgbShiftB: IUniform<number>
u_waveFrequency: IUniform<number>
u_waveStrength: IUniform<number>
u_waveSpeed: IUniform<number>
u_randomFrequency: IUniform<number>
u_randomStrength: IUniform<number>
u_randomSpeed: IUniform<number>
u_time: IUniform<number>
}
export interface ShaderParams {
squareSize: number
lensDistortion: number
rgbShiftR: number
rgbShiftG: number
rgbShiftB: number
waveFrequency: number
waveStrength: number
waveSpeed: number
randomFrequency: number
randomStrength: number
randomSpeed: number
pointerEase: number
}
export default class Mesh {
private scene: Scene
private uniforms: Uniforms
private $target: HTMLElement
private geometry: PlaneGeometry | null
private material: RawShaderMaterial | null
private mesh: ThreeMesh | null
private windowWidth: number
private windowHeight: number
private mouse: Vector2
private mouseEase: Vector2
private pointerEase: number
constructor({ scene, $target, shaderParams }: MeshOptions) {
this.uniforms = {
u_texture1: { value: null },
u_texture2: { value: null },
u_meshSize: { value: new Vector2(1, 1) },
u_textureSize1: { value: new Vector2(1, 1) },
u_textureSize2: { value: new Vector2(1, 1) },
u_mouse: { value: new Vector2() },
u_squareSize: { value: shaderParams.squareSize },
u_lensDistortion: { value: shaderParams.lensDistortion },
u_rgbShiftR: { value: shaderParams.rgbShiftR },
u_rgbShiftG: { value: shaderParams.rgbShiftG },
u_rgbShiftB: { value: shaderParams.rgbShiftB },
u_waveFrequency: { value: shaderParams.waveFrequency },
u_waveStrength: { value: shaderParams.waveStrength },
u_waveSpeed: { value: shaderParams.waveSpeed },
u_randomFrequency: { value: shaderParams.randomFrequency },
u_randomStrength: { value: shaderParams.randomStrength },
u_randomSpeed: { value: shaderParams.randomSpeed },
u_time: { value: 0 }
}
this.scene = scene
this.$target = $target
this.geometry = null
this.material = null
this.mesh = null
this.windowWidth = 0
this.windowHeight = 0
this.pointerEase = shaderParams.pointerEase
this.mouse = new Vector2()
this.mouseEase = new Vector2()
}
public async init(): Promise<void> {
this.setWindowSize()
await this.setTexture()
this.setMesh()
this.setMeshScale()
}
public destroy(): void {
if (this.mesh) {
this.scene.remove(this.mesh)
}
if (this.material) {
this.material.dispose()
}
if (this.uniforms.u_texture1.value) {
this.uniforms.u_texture1.value.dispose()
}
if (this.uniforms.u_texture2.value) {
this.uniforms.u_texture2.value.dispose()
}
if (this.geometry) {
this.geometry.dispose()
}
this.mesh = null
}
private setWindowSize(): void {
this.windowWidth = window.innerWidth
this.windowHeight = window.innerHeight
}
private async setTexture(): Promise<void> {
const loader = new TextureLoader()
const texture1Path = this.$target.dataset.texture1Path
const texture2Path = this.$target.dataset.texture2Path
if (!texture1Path || !texture2Path) return
const [texture1, texture2] = await Promise.all([
loader.loadAsync(texture1Path),
loader.loadAsync(texture2Path)
])
const image1 = texture1.image as {
width: number
height: number
}
const image2 = texture2.image as {
width: number
height: number
}
this.uniforms.u_texture1.value = texture1
this.uniforms.u_texture2.value = texture2
this.uniforms.u_textureSize1.value.set(image1.width, image1.height)
this.uniforms.u_textureSize2.value.set(image2.width, image2.height)
}
private setMesh(): void {
this.geometry = new PlaneGeometry()
this.material = new RawShaderMaterial({
vertexShader: VertexShader,
fragmentShader: FragmentShader,
uniforms: this.uniforms
})
this.mesh = new ThreeMesh(this.geometry, this.material)
this.scene.add(this.mesh)
}
private setMeshScale(): void {
if (!this.mesh) return
this.mesh.scale.set(this.windowWidth, this.windowHeight, 1)
this.uniforms.u_meshSize.value.set(this.mesh.scale.x, this.mesh.scale.y)
}
public setShaderParams({
squareSize,
lensDistortion,
rgbShiftR,
rgbShiftG,
rgbShiftB,
waveFrequency,
waveStrength,
waveSpeed,
randomFrequency,
randomStrength,
randomSpeed,
pointerEase
}: ShaderParams): void {
this.uniforms.u_squareSize.value = squareSize
this.uniforms.u_lensDistortion.value = lensDistortion
this.uniforms.u_rgbShiftR.value = rgbShiftR
this.uniforms.u_rgbShiftG.value = rgbShiftG
this.uniforms.u_rgbShiftB.value = rgbShiftB
this.uniforms.u_waveFrequency.value = waveFrequency
this.uniforms.u_waveStrength.value = waveStrength
this.uniforms.u_waveSpeed.value = waveSpeed
this.uniforms.u_randomFrequency.value = randomFrequency
this.uniforms.u_randomStrength.value = randomStrength
this.uniforms.u_randomSpeed.value = randomSpeed
this.pointerEase = pointerEase
}
public onPointerMove(event: PointerEvent): void {
this.mouse.set(
(event.clientX / this.windowWidth) * 2 - 1,
-(event.clientY / this.windowHeight) * 2 + 1
)
}
public onResize(): void {
this.setWindowSize()
this.setMeshScale()
}
public render(): void {
if (!this.mesh) return
this.mouseEase.lerp(this.mouse, this.pointerEase)
this.uniforms.u_mouse.value.copy(this.mouseEase)
this.uniforms.u_time.value = performance.now() * 0.001
}
}
src/scripts/webgl/stage/Stage.ts
import { PerspectiveCamera, Scene, WebGLRenderer } from "three"
const calcViewportDistance = (height: number, fov: number): number => {
return height / (2 * Math.tan((fov * Math.PI) / 360))
}
interface RendererParams {
canvas: HTMLCanvasElement | undefined
alpha: boolean
}
interface RendererColorParams {
color: number
alpha: number
}
interface StageSize {
width: number
height: number
aspect: {
xy: number
}
}
interface CameraParams {
fov: number
aspect: number
near: number
far: number
}
export default class Stage {
private rendererParams: RendererParams
private rendererColorParams: RendererColorParams
private stageSize: StageSize
private cameraParams: CameraParams
private $canvas: HTMLCanvasElement | null
private renderer: WebGLRenderer | null
private scene: Scene | null
private camera: PerspectiveCamera | null
constructor(options = {}) {
this.rendererParams = {
canvas: undefined,
alpha: true
}
this.rendererColorParams = {
color: 0xffffff,
alpha: 0
}
this.stageSize = {
width: 0,
height: 0,
aspect: {
xy: 0
}
}
this.cameraParams = {
fov: 45,
aspect: 0,
near: 0.01,
far: 10000
}
this.$canvas = null
this.renderer = null
this.scene = null
this.camera = null
Object.assign(this, options)
}
public init(): void {
this.setCanvas()
this.setScene()
this.setStageSize()
this.setCamera()
this.setRenderer()
}
public destroy(): void {
if (this.renderer) {
this.renderer.dispose()
}
this.camera = null
this.scene = null
this.renderer = null
}
private setCanvas(): void {
this.rendererParams.canvas = this.$canvas ?? undefined
}
private setScene(): void {
this.scene = new Scene()
}
private setStageSize(): void {
this.stageSize.width = window.innerWidth
this.stageSize.height = window.innerHeight
this.stageSize.aspect.xy = this.stageSize.width / this.stageSize.height
}
private setCamera(): void {
if (!this.camera) {
this.camera = new PerspectiveCamera(
this.cameraParams.fov,
this.cameraParams.aspect,
this.cameraParams.near,
this.cameraParams.far
)
}
this.camera.aspect = this.stageSize.aspect.xy
this.camera.updateProjectionMatrix()
this.camera.position.z = calcViewportDistance(
this.stageSize.height,
this.camera.fov
)
}
private setRenderer(): void {
if (!this.renderer) {
this.renderer = new WebGLRenderer(this.rendererParams)
}
this.renderer.setClearColor(
this.rendererColorParams.color,
this.rendererColorParams.alpha
)
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
this.renderer.setSize(this.stageSize.width, this.stageSize.height)
}
public render(): void {
if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera)
}
}
public getScene(): Scene | null {
return this.scene
}
public onResize(): void {
this.setStageSize()
this.setRenderer()
this.setCamera()
}
}
src/styles/_base.scss
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--color-text: #f0ede8;
--color-bg: #232121;
--color-link: #f0ede8;
--color-link-hover: #fff;
--page-padding: 1.5rem;
font-size: 12px;
}
html {
text-size-adjust: 100%;
text-rendering: optimizelegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
touch-action: manipulation;
}
body {
min-block-size: 100svb;
margin: 0;
overflow: hidden;
color: var(--color-text);
background-color: var(--color-bg);
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
}
html,
body {
overscroll-behavior: none;
}
img {
block-size: auto;
inline-size: 100%;
max-inline-size: 100%;
vertical-align: middle;
}
a {
color: var(--color-link);
text-decoration: none;
cursor: pointer;
&:hover {
color: var(--color-link-hover);
text-decoration: underline;
}
}
::selection {
color: var(--color-bg);
background-color: var(--color-text);
}
:focus:not(:focus-visible) {
outline: none;
}
:focus-visible {
outline: 2px solid #f00;
outline-offset: 3px;
}
@media (scripting: enabled) {
.loading {
&::before,
&::after {
position: fixed;
z-index: 10000;
content: "";
}
&::before {
inset: 0;
background: var(--color-bg);
}
&::after {
inset-block-start: 50%;
inset-inline-start: 50%;
inline-size: 100px;
block-size: 1px;
margin-inline-start: -50px;
background: var(--color-link);
animation: loader 1.5s ease-in-out infinite alternate forwards;
}
}
}
@keyframes loader {
0% {
transform: scaleX(0);
transform-origin: 0% 50%;
}
50% {
transform: scaleX(1);
transform-origin: 0% 50%;
}
50.1% {
transform: scaleX(1);
transform-origin: 100% 50%;
}
100% {
transform: scaleX(0);
transform-origin: 100% 50%;
}
}
.no-script-message {
position: fixed;
inset: 0;
z-index: 20000;
display: grid;
place-content: center;
gap: 0.75rem;
padding: 2rem;
color: var(--color-text);
text-align: center;
background: var(--color-bg);
h1 {
font-size: 1rem;
}
}
.frame {
position: relative;
z-index: 1000;
display: grid;
grid-template:
"title" auto
"links" auto
"tags" auto
"sponsor" auto / 100%;
gap: 1rem;
justify-items: start;
padding: var(--page-padding);
pointer-events: none;
#cdawrap {
grid-area: sponsor;
justify-self: start;
}
a,
button {
pointer-events: auto;
}
}
.frame-title {
grid-area: title;
margin: 0;
font-size: inherit;
font-weight: 400;
}
.frame-links,
.frame-tags {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.frame-links {
grid-area: links;
}
.frame-tags {
grid-area: tags;
}
@media screen and (width >= 53em) {
.frame {
position: fixed;
inset: 0;
grid-template:
"title links ." auto
"tags tags sponsor" auto / auto auto 1fr;
align-content: space-between;
inline-size: 100%;
block-size: 100%;
#cdawrap {
justify-self: end;
max-inline-size: 300px;
text-align: end;
}
}
}
.webgl-ready [data-mesh] {
opacity: 0;
}
stylelint.config.mjs
export default {
extends: ["stylelint-config-standard-scss"],
ignoreFiles: ["dist/**", "node_modules/**"],
overrides: [
{
files: ["**/*.astro"],
customSyntax: "postcss-html",
},
],
};
Media credits and license evidence실행 안내·자료
README.md
## Credits
- Built with [Astro](https://astro.build/), [Three.js](https://threejs.org/), and [lil-gui](https://lil-gui.georgealways.com/)
- The `random3` GLSL function is based on [Nikita Miropolskiy's Shadertoy example](https://www.shadertoy.com/view/XsX3zB)
- Demo imagery uses [Red rose flower](https://unsplash.com/ja/%E5%86%99%E7%9C%9F/%E8%B5%A4%E3%81%84%E3%83%90%E3%83%A9%E3%81%AE%E8%8A%B1-Y7iHt3LRWGg) by [Kelly Sikkema](https://unsplash.com/@kellysikkema) on Unsplash
## License
[MIT](LICENSE)
LICENSE실행 안내·자료
MIT License
Copyright (c) 2026 Mouse Following Square Lens Effect contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Bundled dependency licenses실행 안내·자료
lil-gui@0.21.0 — LICENSE.md
MIT License
Copyright (c) 2019 George Michael Brower
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
three@0.184.0 — LICENSE
The MIT License
Copyright © 2010-2026 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
BEHIND THE EXAMPLE
이 장면을 만드는 원리
정사각 마스크와 이미지 cover 좌표
화면 크기·입력·동시 작업을 고정하고 실제 렌더링 실패와 비용을 관찰합니다.
- 이 예제에서는
- 셰이더는 메시 비율로 마스크 좌표를 보정하고 렌즈의 변위를 다시 이미지 좌표로 옮깁니다. 안쪽은 채널별 이동을 적용한 첫 이미지, 바깥은 별도로 변형한 두 번째 이미지입니다.
