335 lines
9.7 KiB
JavaScript
335 lines
9.7 KiB
JavaScript
|
import * as twgl from 'https://cdn.skypack.dev/twgl.js/dist/4.x/twgl-full.module';
|
||
|
import * as dat from 'https://cdn.skypack.dev/dat.gui';
|
||
|
import Stats from 'https://cdn.skypack.dev/stats.js';
|
||
|
|
||
|
import * as random from 'https://cdn.skypack.dev/@callumacrae/utils@0.2.4/built/random';
|
||
|
|
||
|
// This is mostly for displaying warnings
|
||
|
const isAndroid = /android/i.test(navigator.userAgent);
|
||
|
const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||
|
const isMobile = isAndroid || isIos;
|
||
|
|
||
|
const imageData = {
|
||
|
sunset: {
|
||
|
src:
|
||
|
'https://sketchbook.macr.ae/assets/particle-photos/leo-nagle-TLuNQu-5xP4-unsplash-small-blurred.jpg',
|
||
|
ratio: 2 / 3,
|
||
|
configPreset: {
|
||
|
particles: isMobile ? 20e3 : 40e3,
|
||
|
color: true,
|
||
|
radiusValExponent: 1.5,
|
||
|
alphaValMultiplier: 0.85,
|
||
|
alphaValExponent: 1.2,
|
||
|
pointSizeMultiplier: isMobile ? 20 : 10,
|
||
|
twinkleFactor: 0
|
||
|
}
|
||
|
},
|
||
|
woman: {
|
||
|
src:
|
||
|
'https://sketchbook.macr.ae/assets/particle-photos/thea-hoyer-CrJyu9HoeBg-unsplash-small-blurred.jpg',
|
||
|
ratio: 2 / 3,
|
||
|
configPreset: {
|
||
|
color: false,
|
||
|
particles: 30e3,
|
||
|
radiusValExponent: 2,
|
||
|
alphaValExponent: 1.5,
|
||
|
alphaValMultiplier: 0.85,
|
||
|
pointSizeMultiplier: 10,
|
||
|
twinkleFactor: 0
|
||
|
}
|
||
|
},
|
||
|
zebra: {
|
||
|
src:
|
||
|
'https://sketchbook.macr.ae/assets/particle-photos/frida-bredesen-c_cPNXlovvY-unsplash-small-blurred.png',
|
||
|
ratio: 1,
|
||
|
configPreset: {
|
||
|
color: false,
|
||
|
particles: 30e3,
|
||
|
radiusValExponent: 2,
|
||
|
alphaValExponent: 1.5,
|
||
|
alphaValMultiplier: 1,
|
||
|
pointSizeMultiplier: 10,
|
||
|
twinkleFrequency: 12,
|
||
|
twinkleIntensity: 25,
|
||
|
twinkleFactor: 0.7
|
||
|
}
|
||
|
},
|
||
|
'bucket hat': {
|
||
|
src: 'https://sketchbook.macr.ae/assets/particle-photos/brandon-webb-FwjkcL9Hpx8-unsplash-small.jpg',
|
||
|
ratio: 3 / 2,
|
||
|
configPreset: {
|
||
|
color: true,
|
||
|
radiusValExponent: 0.7,
|
||
|
alphaValExponent: 0.7,
|
||
|
alphaValMultiplier: 1,
|
||
|
pointSizeMultiplier: 7,
|
||
|
twinkleFactor: 0
|
||
|
}
|
||
|
},
|
||
|
rhino: {
|
||
|
src: 'https://sketchbook.macr.ae/assets/particle-photos/rhino.jpeg',
|
||
|
ratio: 422 / 222,
|
||
|
configPreset: {
|
||
|
particles: 50e3,
|
||
|
radiusValExponent: 1.3,
|
||
|
alphaValExponent: 0.1,
|
||
|
alphaValMultiplier: 1,
|
||
|
color: true,
|
||
|
pointSizeMultiplier: 8,
|
||
|
twinkleFactor: 0.8
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
|
||
|
new Vue({
|
||
|
el: '.canvas-container',
|
||
|
data: () => {
|
||
|
const image = 'rhino';
|
||
|
|
||
|
return {
|
||
|
status: 'playing',
|
||
|
width: undefined,
|
||
|
height: undefined,
|
||
|
showHelp: false,
|
||
|
showIosWarning: false,
|
||
|
userTexture: undefined,
|
||
|
config: {
|
||
|
// Init config
|
||
|
particles: 40e3,
|
||
|
particleBaseSpeed: isMobile ? 8 : 5,
|
||
|
|
||
|
// Shader config
|
||
|
radiusValExponent: 1.5,
|
||
|
alphaValExponent: 1.2,
|
||
|
alphaValMultiplier: 0.85,
|
||
|
color: true,
|
||
|
xInNoiseMultiplier: 200, // Not user configurable, bit buggy
|
||
|
xOutNoiseMultiplier: isMobile ? 0.4 : 0.2,
|
||
|
yInNoiseMultiplier: 1234, // Not user configurable, pointless
|
||
|
yOutNoiseMultiplier: isMobile ? 0.004 : 0.002,
|
||
|
pointSizeMultiplier: 10,
|
||
|
twinkleFrequency: 12,
|
||
|
twinkleIntensity: 25,
|
||
|
twinkleFactor: 0,
|
||
|
|
||
|
image,
|
||
|
...imageData[image].configPreset
|
||
|
}
|
||
|
};
|
||
|
},
|
||
|
mounted() {
|
||
|
document.querySelector('.loading').style.display = 'none';
|
||
|
this.$el.style.display = 'flex';
|
||
|
|
||
|
this.setSize();
|
||
|
this.init().then(() => {
|
||
|
this.frame();
|
||
|
});
|
||
|
|
||
|
const gui = new dat.GUI();
|
||
|
this.gui = gui;
|
||
|
|
||
|
gui
|
||
|
.add(this.config, 'image', Object.keys(imageData).concat('user'))
|
||
|
.listen();
|
||
|
gui.add(this.config, 'particles', 5000, 100000, 1000).listen();
|
||
|
gui.add(this.config, 'particleBaseSpeed', 0, 50).listen();
|
||
|
gui.add(this.config, 'radiusValExponent', 0.1, 10).listen();
|
||
|
gui.add(this.config, 'alphaValExponent', 0.1, 10).listen();
|
||
|
gui.add(this.config, 'alphaValMultiplier', 0.1, 1).listen();
|
||
|
gui.add(this.config, 'color').listen();
|
||
|
gui.add(this.config, 'xOutNoiseMultiplier', 0, 1).listen();
|
||
|
gui.add(this.config, 'yOutNoiseMultiplier', 0, 0.1).listen();
|
||
|
gui.add(this.config, 'pointSizeMultiplier', 1, 30).listen();
|
||
|
gui.add(this.config, 'twinkleFrequency', 1, 50).listen();
|
||
|
gui.add(this.config, 'twinkleIntensity', 1, 50).listen();
|
||
|
gui.add(this.config, 'twinkleFactor', 0, 1).listen();
|
||
|
|
||
|
this.stats = new Stats();
|
||
|
this.stats.showPanel(0); // 0: fps, 1: ms, 2: mb, 3+: custom
|
||
|
document.body.appendChild(this.stats.dom);
|
||
|
|
||
|
if (isIos) {
|
||
|
this.status = 'paused';
|
||
|
this.showIosWarning = true;
|
||
|
gui.close();
|
||
|
}
|
||
|
},
|
||
|
beforeDestroy() {
|
||
|
cancelAnimationFrame(this.frameId);
|
||
|
|
||
|
if (this.gui) {
|
||
|
this.gui.destroy();
|
||
|
}
|
||
|
if (this.stats) {
|
||
|
this.stats.dom.remove();
|
||
|
delete this.stats;
|
||
|
}
|
||
|
},
|
||
|
methods: {
|
||
|
setSize() {
|
||
|
const canvas = this.$refs.canvas;
|
||
|
this.gl = canvas.getContext('webgl', { preserveDrawingBuffer: true });
|
||
|
|
||
|
this.dpr = window.devicePixelRatio;
|
||
|
if (window.innerWidth < window.innerHeight * this.imageData.ratio) {
|
||
|
this.width = window.innerWidth * this.dpr;
|
||
|
this.height = (window.innerWidth / this.imageData.ratio) * this.dpr;
|
||
|
} else {
|
||
|
this.height = window.innerHeight * this.dpr;
|
||
|
this.width = window.innerHeight * this.imageData.ratio * this.dpr;
|
||
|
}
|
||
|
|
||
|
canvas.width = this.width;
|
||
|
canvas.height = this.height;
|
||
|
},
|
||
|
init() {
|
||
|
const { gl, config } = this;
|
||
|
|
||
|
gl.enable(gl.BLEND);
|
||
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||
|
|
||
|
this.programInfo = twgl.createProgramInfo(gl, ['vs', 'fs']);
|
||
|
|
||
|
const particleData = {
|
||
|
xs: [],
|
||
|
initialOffsets: [],
|
||
|
speeds: []
|
||
|
};
|
||
|
for (let i = 0; i < config.particles; i++) {
|
||
|
particleData.xs.push(i / config.particles);
|
||
|
particleData.initialOffsets.push(random.range(-1, 1));
|
||
|
particleData.speeds.push(random.range(0, config.particleBaseSpeed));
|
||
|
}
|
||
|
|
||
|
this.particleData = particleData;
|
||
|
|
||
|
twgl.setAttributePrefix('a_');
|
||
|
this.bufferInfo = twgl.createBufferInfoFromArrays(gl, {
|
||
|
x: {
|
||
|
numComponents: 1,
|
||
|
data: particleData.xs
|
||
|
},
|
||
|
initial_offset: {
|
||
|
numComponents: 1,
|
||
|
data: particleData.initialOffsets
|
||
|
},
|
||
|
speed: {
|
||
|
numComponents: 1,
|
||
|
data: particleData.speeds
|
||
|
}
|
||
|
});
|
||
|
twgl.setBuffersAndAttributes(gl, this.programInfo, this.bufferInfo);
|
||
|
|
||
|
if (this.images) {
|
||
|
return Promise.resolve();
|
||
|
}
|
||
|
|
||
|
return new Promise(resolve => {
|
||
|
// Use a blurred image instead of sampling an area of more than one pixel
|
||
|
// in the fragment shader - do the work ahead of time!
|
||
|
this.images = twgl.createTextures(gl, imageData, resolve);
|
||
|
});
|
||
|
},
|
||
|
frame(timestamp = 0) {
|
||
|
if (this.status !== 'recording') {
|
||
|
this.frameId = requestAnimationFrame(this.frame);
|
||
|
}
|
||
|
|
||
|
if (this.status === 'paused') {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
this.stats.begin();
|
||
|
|
||
|
const { gl, programInfo, bufferInfo, width, height, config } = this;
|
||
|
|
||
|
gl.viewport(0, 0, width, height);
|
||
|
gl.useProgram(programInfo.program);
|
||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||
|
|
||
|
const uniforms = {
|
||
|
u_time: timestamp,
|
||
|
u_image_texture:
|
||
|
config.image === 'user'
|
||
|
? this.userTexture.texture
|
||
|
: this.images[config.image],
|
||
|
u_width: width,
|
||
|
u_height: height,
|
||
|
u_dpr: this.dpr,
|
||
|
u_radius_val_exponent: config.radiusValExponent,
|
||
|
u_alpha_val_exponent: config.alphaValExponent,
|
||
|
u_alpha_val_multiplier: config.alphaValMultiplier,
|
||
|
u_color: config.color,
|
||
|
u_x_in_noise_multiplier: config.xInNoiseMultiplier,
|
||
|
u_x_out_noise_multiplier: config.xOutNoiseMultiplier,
|
||
|
u_y_in_noise_multiplier: config.yInNoiseMultiplier,
|
||
|
u_y_out_noise_multiplier: config.yOutNoiseMultiplier,
|
||
|
u_point_size_multiplier: config.pointSizeMultiplier,
|
||
|
u_twinkle_frequency: config.twinkleFrequency,
|
||
|
u_twinkle_intensity: config.twinkleIntensity,
|
||
|
u_twinkle_factor: config.twinkleFactor
|
||
|
};
|
||
|
|
||
|
twgl.setUniforms(programInfo, uniforms);
|
||
|
twgl.drawBufferInfo(gl, bufferInfo, gl.POINTS);
|
||
|
|
||
|
this.stats.end();
|
||
|
},
|
||
|
handleDrop(e) {
|
||
|
const image = e.dataTransfer.files[0];
|
||
|
if (!image.type.startsWith('image/')) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const reader = new FileReader();
|
||
|
reader.onload = () => {
|
||
|
const img = document.createElement('img');
|
||
|
|
||
|
img.onload = () => {
|
||
|
const texture = twgl.createTexture(this.gl, { src: img });
|
||
|
this.userTexture = { texture, ratio: img.width / img.height };
|
||
|
this.config.image = 'user';
|
||
|
this.setSize();
|
||
|
|
||
|
if (img.width > 500 && img.height > 500) {
|
||
|
setTimeout(() => {
|
||
|
alert(
|
||
|
'Heads up, this works better with low resolution images: see the help for more info'
|
||
|
);
|
||
|
});
|
||
|
}
|
||
|
};
|
||
|
|
||
|
img.src = reader.result;
|
||
|
};
|
||
|
reader.readAsDataURL(image);
|
||
|
}
|
||
|
},
|
||
|
computed: {
|
||
|
imageData() {
|
||
|
return this.config.image === 'user'
|
||
|
? this.userTexture
|
||
|
: imageData[this.config.image];
|
||
|
}
|
||
|
},
|
||
|
watch: {
|
||
|
'config.particles': 'init',
|
||
|
'config.particleBaseSpeed': 'init',
|
||
|
'config.image'(image, oldImage) {
|
||
|
if (image === 'user' && !this.userTexture) {
|
||
|
alert('Drag and drop an image onto this window to test your own image');
|
||
|
this.config.image = oldImage;
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
this.setSize();
|
||
|
|
||
|
if (this.imageData.configPreset) {
|
||
|
for (let [key, value] of Object.entries(this.imageData.configPreset)) {
|
||
|
this.config[key] = value;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
});
|