561 lines
20 KiB
HTML
561 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Winter Snowfall</title>
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap');
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
overflow: hidden;
|
|
background: linear-gradient(180deg, #0f172a 0%, #1e293b 50%, #334155 100%);
|
|
font-family: 'Space Mono', monospace;
|
|
}
|
|
|
|
#canvas {
|
|
display: block;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
}
|
|
|
|
.ui-overlay {
|
|
position: fixed;
|
|
top: 24px;
|
|
left: 24px;
|
|
color: rgba(255, 255, 255, 0.85);
|
|
z-index: 100;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.ui-stats {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.stat-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
font-size: 13px;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.stat-label {
|
|
color: rgba(148, 163, 184, 0.8);
|
|
text-transform: uppercase;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.stat-value {
|
|
color: #e2e8f0;
|
|
font-weight: 700;
|
|
min-width: 60px;
|
|
}
|
|
|
|
.fps-indicator {
|
|
display: inline-block;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: #22c55e;
|
|
margin-right: 8px;
|
|
animation: pulse 1s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; transform: scale(1); }
|
|
50% { opacity: 0.6; transform: scale(0.9); }
|
|
}
|
|
|
|
.title {
|
|
position: fixed;
|
|
bottom: 40px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
color: rgba(255, 255, 255, 0.4);
|
|
font-size: 12px;
|
|
letter-spacing: 0.3em;
|
|
text-transform: uppercase;
|
|
z-index: 100;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.ground-fade {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 120px;
|
|
background: linear-gradient(to top, rgba(241, 245, 249, 0.15) 0%, transparent 100%);
|
|
pointer-events: none;
|
|
z-index: 10;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="canvas"></canvas>
|
|
|
|
<div class="ground-fade"></div>
|
|
|
|
<div class="ui-overlay">
|
|
<div class="ui-stats">
|
|
<div class="stat-item">
|
|
<span class="fps-indicator"></span>
|
|
<span class="stat-label">FPS</span>
|
|
<span class="stat-value" id="fps">60</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">Snowflakes</span>
|
|
<span class="stat-value" id="count">0</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">Wind</span>
|
|
<span class="stat-value" id="wind">0.0</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="title">Winter Snowfall</div>
|
|
|
|
<script type="module">
|
|
import * as THREE from 'https://cdn.skypack.dev/three@0.136.0';
|
|
|
|
// Configuration
|
|
const CONFIG = {
|
|
snowflakeCount: 1200,
|
|
minSize: 2,
|
|
maxSize: 8,
|
|
minSpeed: 0.3,
|
|
maxSpeed: 1.8,
|
|
windStrength: 0.3,
|
|
rotationSpeed: 0.02
|
|
};
|
|
|
|
// State
|
|
let scene, camera, renderer;
|
|
let snowSystem, snowGeometry, snowMaterial;
|
|
let snowData = [];
|
|
let frameCount = 0;
|
|
let lastTime = performance.now();
|
|
let windOffset = 0;
|
|
|
|
// UI Elements
|
|
const fpsEl = document.getElementById('fps');
|
|
const countEl = document.getElementById('count');
|
|
const windEl = document.getElementById('wind');
|
|
|
|
// Generate snowflake textures
|
|
function createSnowflakeTexture(type) {
|
|
const canvas = document.createElement('canvas');
|
|
const size = 64;
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
const centerX = size / 2;
|
|
const centerY = size / 2;
|
|
|
|
ctx.translate(centerX, centerY);
|
|
|
|
// Create gradient for glow effect
|
|
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, size / 2);
|
|
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
|
|
gradient.addColorStop(0.3, 'rgba(200, 230, 255, 0.9)');
|
|
gradient.addColorStop(0.6, 'rgba(150, 200, 255, 0.5)');
|
|
gradient.addColorStop(1, 'rgba(100, 150, 255, 0)');
|
|
|
|
ctx.fillStyle = gradient;
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, size / 2 - 2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Draw snowflake shape based on type
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.lineCap = 'round';
|
|
|
|
if (type === 'hexagon') {
|
|
// Six-pointed star
|
|
for (let i = 0; i < 6; i++) {
|
|
ctx.save();
|
|
ctx.rotate((i * 60 * Math.PI) / 180);
|
|
|
|
// Main arm
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(0, size * 0.35);
|
|
ctx.stroke();
|
|
|
|
// Side branches
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, size * 0.15);
|
|
ctx.lineTo(size * 0.1, size * 0.22);
|
|
ctx.moveTo(0, size * 0.15);
|
|
ctx.lineTo(-size * 0.1, size * 0.22);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
} else if (type === 'star') {
|
|
// Eight-pointed star with cross pattern
|
|
for (let i = 0; i < 8; i++) {
|
|
ctx.save();
|
|
ctx.rotate((i * 45 * Math.PI) / 180);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(0, size * 0.3);
|
|
ctx.stroke();
|
|
|
|
if (i % 2 === 0) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, size * 0.12);
|
|
ctx.lineTo(size * 0.08, size * 0.18);
|
|
ctx.moveTo(0, size * 0.12);
|
|
ctx.lineTo(-size * 0.08, size * 0.18);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
} else {
|
|
// Crystal pattern
|
|
for (let i = 0; i < 6; i++) {
|
|
ctx.save();
|
|
ctx.rotate((i * 60 * Math.PI) / 180);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(0, size * 0.4);
|
|
ctx.stroke();
|
|
|
|
// Multiple branches
|
|
for (let j = 1; j <= 3; j++) {
|
|
const y = size * 0.1 * j;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(size * 0.06, y + size * 0.05);
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(-size * 0.06, y + size * 0.05);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
// Center dot
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 1)';
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, 2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.needsUpdate = true;
|
|
return texture;
|
|
}
|
|
|
|
// Initialize scene
|
|
function init() {
|
|
const canvas = document.getElementById('canvas');
|
|
|
|
// Scene setup
|
|
scene = new THREE.Scene();
|
|
// Fog for depth and atmosphere
|
|
scene.fog = new THREE.FogExp2(0x1e293b, 0.002);
|
|
|
|
// Camera
|
|
camera = new THREE.PerspectiveCamera(
|
|
60,
|
|
window.innerWidth / window.innerHeight,
|
|
0.1,
|
|
1000
|
|
);
|
|
camera.position.z = 100;
|
|
camera.position.y = 20;
|
|
camera.lookAt(0, -20, 0);
|
|
|
|
// Renderer
|
|
renderer = new THREE.WebGLRenderer({
|
|
canvas,
|
|
antialias: true,
|
|
alpha: true
|
|
});
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
|
|
// Create snowflake textures
|
|
const textures = [
|
|
createSnowflakeTexture('hexagon'),
|
|
createSnowflakeTexture('star'),
|
|
createSnowflakeTexture('crystal')
|
|
];
|
|
|
|
// Create snow particle system
|
|
createSnowSystem(textures);
|
|
|
|
// Update UI
|
|
countEl.textContent = CONFIG.snowflakeCount;
|
|
|
|
// Event listeners
|
|
window.addEventListener('resize', onWindowResize, false);
|
|
|
|
// Start animation
|
|
animate();
|
|
}
|
|
|
|
function createSnowSystem(textures) {
|
|
snowGeometry = new THREE.BufferGeometry();
|
|
|
|
const positions = new Float32Array(CONFIG.snowflakeCount * 3);
|
|
const colors = new Float32Array(CONFIG.snowflakeCount * 3);
|
|
const sizes = new Float32Array(CONFIG.snowflakeCount);
|
|
const rotations = new Float32Array(CONFIG.snowflakeCount);
|
|
const rotationSpeeds = new Float32Array(CONFIG.snowflakeCount);
|
|
const speeds = new Float32Array(CONFIG.snowflakeCount);
|
|
const textureIndices = new Float32Array(CONFIG.snowflakeCount);
|
|
const opacities = new Float32Array(CONFIG.snowflakeCount);
|
|
const wobble = new Float32Array(CONFIG.snowflakeCount);
|
|
|
|
const color1 = new THREE.Color(0xffffff); // White
|
|
const color2 = new THREE.Color(0xb8e6ff); // Light blue
|
|
const color3 = new THREE.Color(0xe0f2fe); // Very light blue
|
|
|
|
for (let i = 0; i < CONFIG.snowflakeCount; i++) {
|
|
// Random position across the view
|
|
positions[i * 3] = (Math.random() - 0.5) * 200; // x
|
|
positions[i * 3 + 1] = Math.random() * 150 - 20; // y
|
|
positions[i * 3 + 2] = (Math.random() - 0.5) * 100; // z
|
|
|
|
// Random size
|
|
sizes[i] = CONFIG.minSize + Math.random() * (CONFIG.maxSize - CONFIG.minSize);
|
|
|
|
// Random rotation and speed
|
|
rotations[i] = Math.random() * Math.PI * 2;
|
|
rotationSpeeds[i] = (Math.random() - 0.5) * CONFIG.rotationSpeed * 2;
|
|
speeds[i] = CONFIG.minSpeed + Math.random() * (CONFIG.maxSpeed - CONFIG.minSpeed);
|
|
|
|
// Random texture
|
|
textureIndices[i] = Math.floor(Math.random() * textures.length);
|
|
|
|
// Random wobble for wind effect
|
|
wobble[i] = Math.random() * Math.PI * 2;
|
|
|
|
// Color gradient from white to light blue
|
|
const colorMix = Math.random();
|
|
let finalColor;
|
|
if (colorMix < 0.5) {
|
|
finalColor = color1.clone().lerp(color3, colorMix * 2);
|
|
} else {
|
|
finalColor = color3.clone().lerp(color2, (colorMix - 0.5) * 2);
|
|
}
|
|
|
|
colors[i * 3] = finalColor.r;
|
|
colors[i * 3 + 1] = finalColor.g;
|
|
colors[i * 3 + 2] = finalColor.b;
|
|
|
|
// Initial opacity
|
|
opacities[i] = 1.0;
|
|
|
|
// Store particle data for animation
|
|
snowData.push({
|
|
originalY: positions[i * 3 + 1],
|
|
originalSize: sizes[i],
|
|
speed: speeds[i],
|
|
wobblePhase: wobble[i],
|
|
wobbleSpeed: 0.5 + Math.random() * 1.5
|
|
});
|
|
}
|
|
|
|
snowGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
snowGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
|
snowGeometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
|
|
snowGeometry.setAttribute('rotation', new THREE.BufferAttribute(rotations, 1));
|
|
snowGeometry.setAttribute('opacity', new THREE.BufferAttribute(opacities, 1));
|
|
|
|
// Shader material for advanced rendering
|
|
const vertexShader = `
|
|
attribute float size;
|
|
attribute float rotation;
|
|
attribute float opacity;
|
|
varying float vRotation;
|
|
varying float vOpacity;
|
|
varying vec3 vColor;
|
|
|
|
void main() {
|
|
vRotation = rotation;
|
|
vOpacity = opacity;
|
|
vColor = color;
|
|
|
|
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
|
|
gl_PointSize = size * (300.0 / -mvPosition.z);
|
|
gl_Position = projectionMatrix * mvPosition;
|
|
}
|
|
`;
|
|
|
|
const fragmentShader = `
|
|
uniform sampler2D texture1;
|
|
uniform sampler2D texture2;
|
|
uniform sampler2D texture3;
|
|
uniform float textureIndex;
|
|
|
|
varying float vRotation;
|
|
varying float vOpacity;
|
|
varying vec3 vColor;
|
|
|
|
void main() {
|
|
// Rotate texture coordinates
|
|
vec2 center = vec2(0.5, 0.5);
|
|
vec2 uv = gl_PointCoord - center;
|
|
float s = sin(vRotation);
|
|
float c = cos(vRotation);
|
|
vec2 rotatedUv = vec2(
|
|
uv.x * c - uv.y * s,
|
|
uv.x * s + uv.y * c
|
|
) + center;
|
|
|
|
// Sample from texture atlas or use default circle
|
|
vec4 texColor = texture2D(texture1, rotatedUv);
|
|
|
|
if (texColor.a < 0.1) discard;
|
|
|
|
gl_FragColor = vec4(vColor * texColor.rgb, texColor.a * vOpacity);
|
|
}
|
|
`;
|
|
|
|
// Use PointsMaterial for simplicity and better performance
|
|
snowMaterial = new THREE.PointsMaterial({
|
|
size: 1,
|
|
vertexColors: true,
|
|
map: textures[0],
|
|
transparent: true,
|
|
opacity: 0.9,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
sizeAttenuation: true
|
|
});
|
|
|
|
snowSystem = new THREE.Points(snowGeometry, snowMaterial);
|
|
scene.add(snowSystem);
|
|
}
|
|
|
|
function updateSnow(deltaTime) {
|
|
const positions = snowGeometry.attributes.position.array;
|
|
const rotations = snowGeometry.attributes.rotation.array;
|
|
const sizes = snowGeometry.attributes.size.array;
|
|
const opacities = snowGeometry.attributes.opacity.array;
|
|
|
|
// Update wind
|
|
windOffset += deltaTime * 0.5;
|
|
const currentWind = Math.sin(windOffset * 0.3) * CONFIG.windStrength +
|
|
Math.sin(windOffset * 0.7) * CONFIG.windStrength * 0.5;
|
|
|
|
// Update UI
|
|
windEl.textContent = currentWind.toFixed(2);
|
|
|
|
const groundLevel = -50;
|
|
const fadeStart = -30;
|
|
|
|
for (let i = 0; i < CONFIG.snowflakeCount; i++) {
|
|
const data = snowData[i];
|
|
|
|
// Apply gravity
|
|
positions[i * 3 + 1] -= data.speed * deltaTime * 30;
|
|
|
|
// Apply wind with wobble
|
|
data.wobblePhase += data.wobbleSpeed * deltaTime;
|
|
const wobbleOffset = Math.sin(data.wobblePhase) * 0.3;
|
|
positions[i * 3] += (currentWind + wobbleOffset) * deltaTime * 20;
|
|
|
|
// Rotate
|
|
rotations[i] += data.rotationSpeed * deltaTime * 2;
|
|
|
|
// Ground accumulation effect - fade out when near ground
|
|
const y = positions[i * 3 + 1];
|
|
if (y < fadeStart) {
|
|
const fadeProgress = (y - groundLevel) / (fadeStart - groundLevel);
|
|
opacities[i] = Math.max(0, Math.min(1, fadeProgress));
|
|
|
|
// Scale down near ground
|
|
const scale = Math.max(0.3, fadeProgress);
|
|
sizes[i] = data.originalSize * scale;
|
|
} else {
|
|
opacities[i] = 1;
|
|
sizes[i] = data.originalSize;
|
|
}
|
|
|
|
// Reset if below ground
|
|
if (y < groundLevel) {
|
|
positions[i * 3 + 1] = 100 + Math.random() * 50;
|
|
positions[i * 3] = (Math.random() - 0.5) * 200;
|
|
positions[i * 3 + 2] = (Math.random() - 0.5) * 100;
|
|
opacities[i] = 1;
|
|
sizes[i] = data.originalSize;
|
|
}
|
|
|
|
// Wrap around horizontally
|
|
if (positions[i * 3] > 100) {
|
|
positions[i * 3] = -100;
|
|
} else if (positions[i * 3] < -100) {
|
|
positions[i * 3] = 100;
|
|
}
|
|
}
|
|
|
|
snowGeometry.attributes.position.needsUpdate = true;
|
|
snowGeometry.attributes.rotation.needsUpdate = true;
|
|
snowGeometry.attributes.size.needsUpdate = true;
|
|
snowGeometry.attributes.opacity.needsUpdate = true;
|
|
}
|
|
|
|
function updateFPS() {
|
|
frameCount++;
|
|
const currentTime = performance.now();
|
|
|
|
if (currentTime - lastTime >= 1000) {
|
|
fpsEl.textContent = frameCount;
|
|
frameCount = 0;
|
|
lastTime = currentTime;
|
|
|
|
// Update FPS indicator color
|
|
const indicator = document.querySelector('.fps-indicator');
|
|
if (frameCount >= 55) {
|
|
indicator.style.background = '#22c55e'; // Green
|
|
} else if (frameCount >= 30) {
|
|
indicator.style.background = '#eab308'; // Yellow
|
|
} else {
|
|
indicator.style.background = '#ef4444'; // Red
|
|
}
|
|
}
|
|
}
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
|
|
const deltaTime = 0.016; // Approximate 60fps delta
|
|
|
|
updateSnow(deltaTime);
|
|
updateFPS();
|
|
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
function onWindowResize() {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
}
|
|
|
|
// Initialize
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|