
π index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebGL2 Shader Playground</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="errBox"></div>
<div class="container">
<canvas id="canvas" width="640" height="640"></canvas>
<div class="controls-panel">
<div class="control-group color-group">
<div class="color-display">
<div id="colorSwatch"></div>
</div>
<input
type="color"
id="colorPicker"
value="#ff6b6b"
class="color-input"
/>
</div>
<div class="control-group rotation-group">
<div class="rotation-header">
<label for="rotSlider">Rotation</label>
<span id="rotVal" class="rotation-value">0Β°</span>
</div>
<input
type="range"
id="rotSlider"
min="0"
max="360"
value="0"
class="slider"
/>
</div>
</div>
</div>
<script src="main.js"></script>
</body>
</html>
π style.css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: linear-gradient(135deg, #d4d4ed 0%, #1b1b25 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
color: #e8e8f0;
}
.container {
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
}
canvas {
display: block;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
background: #0a0a14;
}
.controls-panel {
display: flex;
gap: 48px;
padding: 32px 40px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.control-group {
display: flex;
flex-direction: column;
gap: 16px;
}
.color-group {
align-items: center;
gap: 20px;
}
.color-display {
position: relative;
}
#colorSwatch {
width: 80px;
height: 80px;
border-radius: 12px;
background: #ff6b6b;
box-shadow:
0 8px 24px rgba(255, 255, 255, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid rgba(255, 255, 255, 0.1);
}
#colorSwatch:hover {
transform: scale(1.05);
box-shadow:
0 12px 32px rgba(255, 107, 107, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.color-input {
width: 56px;
height: 56px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
cursor: pointer;
opacity: 0;
position: absolute;
pointer-events: auto;
}
.rotation-group {
min-width: 240px;
}
.rotation-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.rotation-header label {
font-size: 13px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1.2px;
color: #a8a8b8;
}
.rotation-value {
font-size: 18px;
font-weight: 600;
color: #ffffff;
min-width: 48px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: linear-gradient(
to right,
rgba(255, 255, 255, 0.3),
rgba(255, 255, 255, 0.2)
);
outline: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
cursor: pointer;
box-shadow:
0 4px 12px rgba(255, 255, 255, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.4);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
transform: translateY(-35%);
}
.slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
box-shadow:
0 6px 20px rgba(255, 255, 255, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.slider::-webkit-slider-thumb:active {
transform: scale(1.15);
box-shadow:
0 4px 12px rgba(255, 255, 255, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
cursor: pointer;
box-shadow:
0 4px 12px rgba(255, 255, 255, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.4);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.slider::-moz-range-thumb:hover {
transform: scale(1.2);
box-shadow:
0 6px 20px rgba(255, 255, 255, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.slider::-webkit-slider-runnable-track {
background: linear-gradient(
to right,
rgba(255, 255, 255, 0.3),
rgba(255, 255, 255, 0.2)
);
height: 6px;
border-radius: 3px;
border: none;
}
#errBox {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
background: rgba(100, 30, 30, 0.95);
color: #ff9999;
padding: 16px 24px;
z-index: 999;
font-size: 13px;
white-space: pre-wrap;
max-height: 50vh;
overflow: auto;
border-bottom: 2px solid rgba(255, 107, 107, 0.5);
line-height: 1.5;
backdrop-filter: blur(8px);
}
@media (max-width: 768px) {
.container {
gap: 20px;
padding: 16px;
}
canvas {
width: 100%;
max-width: 400px;
height: auto;
}
.controls-panel {
flex-direction: column;
gap: 28px;
padding: 24px 28px;
width: 100%;
max-width: 400px;
}
.rotation-group {
min-width: auto;
}
}
.slider:focus {
outline: 2px solid rgba(255, 255, 255, 0.3);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
#colorSwatch,
.slider::-webkit-slider-thumb,
.slider::-moz-range-thumb {
transition: none;
}
#colorSwatch:hover {
transform: none;
}
.slider::-webkit-slider-thumb:hover {
transform: none;
}
}
π main.js
async function loadShader(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`[X] Shader Loading Failed: ${url}`);
}
return response.text();
}
function showError(msg) {
const box = document.getElementById('errBox');
box.style.display = 'block';
box.textContent = msg;
}
function compileShader(gl, src, type, name) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
const err = gl.getShaderInfoLog(s);
showError(`[X] Shader compile failed (${name}):\n\n${err}\n\nSOURCE:\n${src}`);
gl.deleteShader(s);
return null;
}
return s;
}
function createProgram(gl, vs, fs) {
const prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
showError(`[X] Program link failed:\n\n${gl.getProgramInfoLog(prog)}`);
throw new Error('Program link failed');
}
return prog;
}
function perspective(fov, aspect, near, far) {
const f = 1.0 / Math.tan(fov / 2);
const nf = 1 / (near - far);
return new Float32Array([
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (far + near) * nf, -1,
0, 0, 2 * far * near * nf, 0,
]);
}
function lookAt(eye, center, up) {
let zx = eye[0] - center[0], zy = eye[1] - center[1], zz = eye[2] - center[2];
let l = 1 / Math.hypot(zx, zy, zz);
const z = [zx * l, zy * l, zz * l];
const x0 = up[1] * z[2] - up[2] * z[1], x1 = up[2] * z[0] - up[0] * z[2], x2 = up[0] * z[1] - up[1] * z[0];
l = 1 / Math.hypot(x0, x1, x2);
const x = [x0 * l, x1 * l, x2 * l];
const y = [z[1] * x[2] - z[2] * x[1], z[2] * x[0] - z[0] * x[2], z[0] * x[1] - z[1] * x[0]];
return new Float32Array([
x[0], y[0], z[0], 0,
x[1], y[1], z[1], 0,
x[2], y[2], z[2], 0,
-(x[0] * eye[0] + x[1] * eye[1] + x[2] * eye[2]),
-(y[0] * eye[0] + y[1] * eye[1] + y[2] * eye[2]),
-(z[0] * eye[0] + z[1] * eye[1] + z[2] * eye[2]),
1,
]);
}
function rotateY(angle) {
const c = Math.cos(angle), s = Math.sin(angle);
return new Float32Array([
c, 0, -s, 0,
0, 1, 0, 0,
s, 0, c, 0,
0, 0, 0, 1
]);
}
function hexToRgb(hex) {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
return { r, g, b };
}
async function init() {
const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl2', { antialias: true });
if (!gl) {
showError('WebGL2 not supported');
return;
}
try {
const vertSrc = await loadShader('shaders/vert.glsl');
const fragSrc = await loadShader('shaders/frag.glsl');
const vs = compileShader(gl, vertSrc, gl.VERTEX_SHADER, 'VERTEX');
if (!vs) return;
const fs = compileShader(gl, fragSrc, gl.FRAGMENT_SHADER, 'FRAGMENT');
if (!fs) return;
const prog = createProgram(gl, vs, fs);
gl.useProgram(prog);
const vertices = new Float32Array([
-0.8, -0.8, 0.0, 0.0, 0.0,
0.8, -0.8, 0.0, 1.0, 0.0,
0.8, 0.8, 0.0, 1.0, 1.0,
-0.8, 0.8, 0.0, 0.0, 1.0,
]);
const indices = new Uint16Array([0, 1, 2, 0, 2, 3]);
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 20, 0);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 20, 12);
const ebo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ebo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
gl.bindVertexArray(null);
const locProjection = gl.getUniformLocation(prog, 'uProjection');
const locView = gl.getUniformLocation(prog, 'uView');
const locModel = gl.getUniformLocation(prog, 'uModel');
const locColor = gl.getUniformLocation(prog, 'uColor');
const colorPicker = document.getElementById('colorPicker');
const colorSwatch = document.getElementById('colorSwatch');
const rotSlider = document.getElementById('rotSlider');
const rotVal = document.getElementById('rotVal');
colorPicker.addEventListener('change', (e) => {
colorSwatch.style.background = e.target.value;
});
colorSwatch.style.background = colorPicker.value;
function render() {
const { r, g, b } = hexToRgb(colorPicker.value);
const rot = (rotSlider.value / 180) * Math.PI;
rotVal.textContent = rotSlider.value + 'Β°';
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0.04, 0.04, 0.08, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST);
const aspect = canvas.width / canvas.height;
gl.uniformMatrix4fv(locProjection, false, perspective(Math.PI / 4, aspect, 0.1, 100));
gl.uniformMatrix4fv(locView, false, lookAt([0, 0, 3], [0, 0, 0], [0, 1, 0]));
gl.uniformMatrix4fv(locModel, false, rotateY(rot));
gl.uniform4f(locColor, r, g, b, 1.0);
gl.bindVertexArray(vao);
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
requestAnimationFrame(render);
}
render();
} catch (error) {
showError(`μ΄κΈ°ν μ€λ₯:\n${error.message}`);
console.error(error);
}
}
init();
π vert.glsl
#version 300 es
precision mediump float;
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec2 aTexCoord;
out vec2 vTexCoord;
uniform mat4 uProjection;
uniform mat4 uView;
uniform mat4 uModel;
void main() {
gl_Position = uProjection * uView * uModel * vec4(aPosition, 1.0);
vTexCoord = aTexCoord;
}
π frag.glsl
#version 300 es
precision mediump float;
in vec2 vTexCoord;
out vec4 fragColor;
uniform vec4 uColor;
void main() {
fragColor = uColor;
}