This commit is contained in:
2026-05-23 17:17:56 -07:00
commit 448f2e33ef
135 changed files with 11817 additions and 0 deletions
+405
View File
@@ -0,0 +1,405 @@
<div class="document-section">
<p class="document-header">API Guide</p>
<p class="document-paragraph">Last Updated: April 13th 2026</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[ Introduction ]</p>
<p class="document-paragraph">
The gifuu API is publicly accessible and requires no authentication
for read operations. We recommend that you have the client make requests
on their own instead of proxying requests for them to avoid issues with
our rate limits.
</p>
<p>Use the following URLs for HTTP requests:</p>
<pre class="document-codeblock">
https://api.gifuu.pancakz.net/ // Base URL for API Requests
https://cdn.gifuu.pancakz.net/ // Base URL for CDN Requests
https://cdn.gifuu.pancakz.net/{id}/preview.avif // Up to 240px at 16fps
https://cdn.gifuu.pancakz.net/{id}/standard.avif // Up to 720px at 60fps
https://cdn.gifuu.pancakz.net/{id}/alpha.webm // See "Transparency" section below
https://cdn.gifuu.pancakz.net/{id}/standard.ogg // See "Audio" section below
</pre>
<p>Ratelimit headers are provided with each request:</p>
<pre class="document-codeblock">
X-Ratelimit-Category // Endpoint Category (a.k.a Bucket)
X-Ratelimit-Reset // Seconds until reset (float string)
X-Ratelimit-Limit // Requests allowed per period
X-Ratelimit-Remaining // Requests left before 429 errors appear
</pre>
<div class="document-divider"></div>
<p class="document-header">[ Transparency ]</p>
<p class="document-paragraph">
gifuu stores animations (also referred to as Art) as AVIF a very new and modern format.
We sacrifice compatibility with older devices to gain massive efficiency in file size and visual quality.
Unfortunately AVIF doesn't natively support transparency, so we use a stacked video technique to encode
the alpha channel (transparency) alongside the color data.
</p>
<p class="document-paragraph">
The <code>alpha.webm</code> file is a double-height video where the top half contains the
color data and the bottom half contains the alpha channel encoded as a grayscale luma
(brightness) map. White pixels signify opaque, black pixels signify transparent.
</p>
<p class="document-paragraph">
To render this correctly in the browser you must use a WebGL fragment shader to composite
the two halves together. The following shader can be used as a reference:
</p>
<pre class="document-codeblock">
// Sample color from top half, alpha from bottom half
vec2 colorUV = vec2(vUV.x, vUV.y * 0.5);
vec2 alphaUV = vec2(vUV.x, 0.5 + vUV.y * 0.5);
// Apply colors to a texture
vec4 color = texture2D(uFrame, colorUV);
float alpha = texture2D(uFrame, alphaUV).r;
gl_FragColor = vec4(color.rgb, alpha);
</pre>
<p class="document-paragraph">
This technique was pioneered by
<a target="_blank" href="https://jakearchibald.com/2024/video-with-transparency/">Jake Archibald</a>.
If you don't want to implement this yourself, you can embed our player by clicking the
<code>EMBED</code> button while viewing any animation.
</p>
<p class="document-header">[ Audio ]</p>
<p class="document-paragraph">
You can check for audio by reading the <code>audio</code> field on Art objects, or by
requesting it from the CDN and checking for a <code>404 Not Found</code> response.
</p>
<p class="document-paragraph">
Content is encoded with the <a target="_blank" href="https://en.wikipedia.org/wiki/Opus_(audio_format)">Opus</a>
codec inside an <a target="_blank" href="https://en.wikipedia.org/wiki/Ogg">Ogg</a> container
for compatibility with Apple devices.
</p>
<div class="document-divider"></div>
<p class="document-header">[ Object Types ]</p>
<p class="document-paragraph">The following types are returned as responses across API endpoints:</p>
<p class="document-subheader">Object: Tag</p>
<pre class="document-codeblock">
{
"id": string // Tag ID (snowflake string)
"label": string // Tag Name
"usage": number // Number of animations using this tag
}
</pre>
<p class="document-subheader">Object: Art</p>
<pre class="document-codeblock">
{
"id": string // Animation ID (snowflake string)
"created": string // ISO 8601 Timestamp
"sticker": boolean // Is Static?
"audio": boolean // Has Audio?
"framerate": number // Approximate Framerate
"width": number // Approximate Width
"height": number // Approximate Height
"rating": string // NSFW Rating (string float, range: 0.0 - 1.0)
"title": string // Associated Title
"tags": Tag[] // Associated Tags
}
</pre>
<div class="document-divider"></div>
<p class="document-header">[ Special ]</p>
<div class="document-spacer"></div>
<p class="document-subheader">GET /limits</p>
<p class="document-paragraph">
Returns upload constraints and validation rules for all user input fields.
The regex patterns and normalizer rules here are authoritative.
You must sanitize your inputs against them before submitting or the server will reject your request.
</p>
<pre class="document-codeblock">
Response Body:
{
"upload": {
"input_width_min": number // Minimum input width (64px)
"input_height_min": number // Minimum input height (64px)
"video_width_max": number // Maximum video width (3840px)
"video_height_max": number // Maximum video height (2160px)
"image_width_max": number // Maximum image width (7680px)
"image_height_max": number // Maximum image height (4320px)
"duration": number // Maximum duration in seconds (62s)
"filesize": number // Maximum file size in bytes
"mime_types": string[] // Accepted MIME types
}
"title": {
"normalizers": NormalizerRule[] // Apply before validating
"matcher": string // Regex pattern
"max_length": number // 80
"min_length": number // 1
}
"tag": {
"normalizers": NormalizerRule[]
"matcher": string // ^[\p{L}\p{N}_]{1,32}$
"max_length": number // 32
"min_length": number // 1
}
"comment": {
"normalizers": NormalizerRule[]
"matcher": string
"max_length": number // 240
"min_length": number // 10
}
"report": {
"values": [ // Valid report reason types
{ "id": number, "title": string, "description": string }
]
"normalizers": NormalizerRule[]
"matcher": string
"max_length": number // 240
"min_length": number // 10
}
}
Object: NormalizerRule
{
"match": string // Regex pattern to find
"replace": string // Replacement string
"comment": string // Human-readable description
}
</pre>
<div class="document-spacer"></div>
<p class="document-subheader">GET /challenge</p>
<p class="document-paragraph">
Returns a fresh Proof of Work challenge.
You select the difficulty, the server enforces a minimum of <code>18</code>.
Challenges expire after 5 minutes.
They are consumed immediately upon use even if the request fails.
</p>
<p class="document-paragraph">
Provide your completed counter and given nonce to endpoints that require PoW via the
<code>X-Pow-Counter</code> and <code>X-Pow-Nonce</code> headers respectively.
</p>
<pre class="document-codeblock">
Query Parameters:
difficulty number // Desired difficulty (minimum: 18)
Response Body:
{
"nonce": string // Hex-encoded nonce
"difficulty": number // Confirmed difficulty
"expires": number // Expiry as UNIX timestamp
}
</pre>
<p class="document-paragraph">
Some endpoints enforce a higher minimum difficulty than the global floor.
Request at least the required difficulty for the endpoint you intend to call or it will be rejected:
</p>
<pre class="document-codeblock">
Endpoint Minimum Difficulty
------------------------ ------------------
POST /uploads 20
</pre>
<p class="document-paragraph">Example Solver (JavaScript):</p>
<pre class="document-codeblock">
const { nonce, difficulty } = await fetch("/challenge?difficulty=18").then(r => r.json())
const encoder = new TextEncoder()
let counter = 0
while (true) {
const data = encoder.encode(nonce + counter)
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", data))
let zeroBits = 0
for (const byte of hash) {
if (byte === 0) { zeroBits += 8 }
else { zeroBits += Math.clz32(byte) - 24; break }
}
if (zeroBits >= difficulty) break
counter++
}
// Submit nonce + counter with your upload via X-Pow-Nonce and X-Pow-Counter headers
</pre>
<div class="document-divider"></div>
<p class="document-header">[ Tags ]</p>
<p class="document-paragraph">
gifuu uses tags to make its database queryable. Sanitize tag strings against the rules
provided by <code>limits.tags</code> or the server will reject your request.
</p>
<div class="document-spacer"></div>
<p class="document-subheader">GET /tags/popular</p>
<p class="document-paragraph">Returns the most popular tags (highest usage) on the platform.</p>
<pre class="document-codeblock">
Query Parameters:
limit number // Amount of results to return (range 1-100)
Response Body:
Tag[]
</pre>
<div class="document-spacer"></div>
<p class="document-subheader">GET /tags/autocomplete</p>
<p class="document-paragraph">Search for tags with a similar spelling using word similarity ranking.</p>
<pre class="document-codeblock">
Query Parameters:
query string // Search query — must pass validation rules from 'limits.tag'
limit number // Amount of results to return (range 1-100)
Response Body:
Tag[]
</pre>
<div class="document-divider"></div>
<p class="document-header">[ Art ]</p>
<p class="document-paragraph">
Content is processed and served as AVIF files for efficiency. Most modern web browsers
and operating systems <a target="_blank" href="https://caniuse.com/avif">support this format</a>.
</p>
<div class="document-spacer"></div>
<p class="document-subheader">GET /art/latest</p>
<p class="document-paragraph">Returns the most recently uploaded animations, newest first.</p>
<pre class="document-codeblock">
Query Parameters:
limit number // Amount of results to return (range 1-100)
after string? // Cursor for pagination — snowflake ID of last seen item (optional)
Response Body:
Art[]
</pre>
<div class="document-spacer"></div>
<p class="document-subheader">GET /art/search</p>
<p class="document-paragraph">
Returns animations matching all provided tags (AND logic). At least one
<code>tag</code> parameter is required.
</p>
<pre class="document-codeblock">
Query Parameters:
tag string // Tag ID to filter by (snowflake string) — repeat for multiple tags
limit number // Amount of results to return (range 1-100)
after string? // Cursor for pagination — snowflake ID of last seen item (optional)
// Example: /art/search?tag=123&tag=456&limit=20
Response Body:
Art[]
</pre>
<div class="document-spacer"></div>
<p class="document-subheader">GET /art/{id}</p>
<p class="document-paragraph">Returns metadata for a single animation.</p>
<pre class="document-codeblock">
Response Body:
Art
</pre>
<div class="document-spacer"></div>
<p class="document-subheader">DELETE /art/{id}</p>
<p class="document-paragraph">
Deletes an animation. Requires the edit token returned at upload time,
passed as a query parameter. Responds with <code>204 No Content</code> on success.
</p>
<pre class="document-codeblock">
Query Parameters:
token string // Edit token from upload response
</pre>
<div class="document-spacer"></div>
<p class="document-subheader">POST /art/{id}/reports</p>
<p class="document-paragraph">
Submits a moderation report for an animation.
Valid reason type IDs are listed in <code>limits.report.values</code></code>.
The reason text must pass the <code>report</code> validation rules from the same endpoint.
Responds with <code>204 No Content</code> on success.
</p>
<pre class="document-codeblock">
Request Body:
{
"type": number // Report reason ID (see 'limits.report.values')
"reason": string // Description of the issue (10-240 characters)
}
</pre>
<div class="document-divider"></div>
<p class="document-header">[ Uploads ]</p>
<p class="document-paragraph">Endpoints for creating and monitoring uploads.</p>
<div class="document-spacer"></div>
<p class="document-subheader">POST /uploads</p>
<p class="document-paragraph">
Uploads an animation to the site. To prevent spam this endpoint requires a valid
Proof of Work challenge solved via <code>GET /challenge</code> with a minimum difficulty of <code>20</code>.
</p>
<p class="document-paragraph">
This endpoint responds as a <strong>Server-Sent Events (SSE) stream</strong>. Events are
emitted throughout processing to report progress. The connection closes after the final
<code>finish</code> event or on any error.
</p>
<p class="document-paragraph">
NOTE: Requests exceeding the <code>limits.upload.filesize</code> limit will be aborted immediately.
</p>
<pre class="document-codeblock">
Request Body: [multipart/form-data]
field: data (text/JSON)
{
"title": string // Animation title (1-80 characters, see 'limits.title')
"tags": string[] // Tag names to attach, plaintext (see 'limits.tag')
}
field: file (binary)
// Accepted MIME types may change, fetch the current list from 'limits.upload.mime_types'
image/jpeg, image/png, image/gif, image/webp, image/heic, image/heif,
image/avif, image/jxl, image/tiff, image/bmp,
video/mp4, video/webm, video/quicktime, video/x-matroska,
video/avi, video/x-ms-wmv,
Request Headers:
X-Pow-Nonce // Nonce from GET /challenge
X-Pow-Counter // Your solved counter value
</pre>
<pre class="document-codeblock">
SSE Event Stream:
event: id // Emitted early — the assigned snowflake ID for this upload
{ "id": string }
event: step // Processing stage updates
{ "id": string, "message": string }
// Known step IDs: PROBE_QUEUE, PROBE_START, SERVER_FINALIZE
event: progress // Encoding/classification progress
{ "percent": string } // Float string, e.g. "42.50"
event: finish // Final event on success — save edit_token, it is not recoverable!
{
"id": string // Animation snowflake ID
"edit_token": string // Required to delete this animation later
}
event: error // Emitted on client or server error, stream closes after
{ "code": number, "message": string }
</pre>
</div>
@@ -0,0 +1,51 @@
<div class="document-section">
<p class="document-header">Privacy Policy</p>
<p class="document-paragraph">Last Updated: March 21st 2026</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[ Data Collection ]</p>
<p class="document-paragraph">We collect the following data in order to provide you with our services:</p>
<div class="document-list">
<p class="document-item">Content that you upload</p>
<p class="document-item">Your edit tokens&sup1;</p>
<p class="document-item">Your IP address&sup2;</p>
</div>
<p class="document-paragraph">
&sup1; Edit tokens are kept on your device when using our website.
Please back them up via the settings menu, as we cannot assist in recovery.
</p>
<p class="document-paragraph">
&sup2; Your IP address is stored in hashed form via a one-way algorithm to reduce direct identification.
We use this data to prevent abuse on our platform and issue disciplinary actions towards bad actors.
Decisions are made at our discretion and are final.
</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[ Third Party ]</p>
<p class="document-paragraph">
We use a self-hosted instance of <a target="_blank" href="https://umami.is/">Umami</a>
for analytics to see how people arrive at and interact with our site.
We use <a href="https://en.wikipedia.org/wiki/UTM_parameters">UTM parameters</a>,
which can be manually removed if desired.
</p>
<p class="document-paragraph">
All of this data is stored anonymously on our own servers and isnt shared with any third parties.
</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[ Contact ]</p>
<p class="document-paragraph">
gifuu is a personal project operated by bakonpancakz.
For privacy or legal concerns, please visit:
<a target="_blank" href="https://pancakz.net/">https://pancakz.net/</a>
</p>
</div>
@@ -0,0 +1,69 @@
<div class="document-section">
<p class="document-header">Terms of Service</p>
<p class="document-paragraph">Last Updated: March 21st 2026</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[1] Acceptance</p>
<p class="document-paragraph">
By using gifuu, you agree to the following terms of service.
If you do not agree to these terms, do not use our platform.
</p>
<p class="document-paragraph">
These terms may be updated at any time without prior notice. Continued use constitutes acceptance.
</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[2] Content</p>
<p class="document-paragraph">
You are solely responsible for any content you upload to gifuu.
You may not upload, distribute, or store content that:
</p>
<p class="document-paragraph">
Violates any applicable law or regulation;
infringes on any copyright, trademark, or other intellectual property right;
depicts any being in a sexual or exploitative manner;
constitutes targeted harassment, hate speech, or incitement of violence;
depicts graphic violence, gore, or abuse;
promotes or depicts self-harm, dangerous activity, or seizure-inducing imagery;
constitutes spam, advertising, or unsolicited solicitation;
or that you do not have the rights to distribute.
</p>
<p class="document-paragraph">
We reserve the right to moderate, remove content, or restrict access to our platform at our discretion.
Violations may be reported by users and are reviewed by our moderation team.
</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[3] Data Collection</p>
<p class="document-paragraph">
You agree to our data collection and privacy policies.
A description of how we collect and process your data is available <a href="/text/privacy-policy">here</a>.
</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[4] Availability</p>
<p class="document-paragraph">
We are not liable for any loss of data or damages resulting from use of the platform.
</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[5] Other</p>
<p class="document-paragraph">
You agree that bunnies are adorable.
</p>
</div>
+106
View File
@@ -0,0 +1,106 @@
:root {
--animation-transition: 200ms;
--border-thickness: 2px;
--background-tertiary: hsl(0, 0%, 0%);
--background-secondary: hsl(0, 0%, 16%);
--background-primary: hsl(0, 0%, 32%);
--font-color-accent: hsl(0, 50%, 80%);
--font-color-primary: hsl(0, 0%, 95%);
--font-color-secondary: hsl(0, 0%, 65%);
}
html,
body {
box-sizing: border-box;
margin: 0;
background: transparent;
padding: 0;
overflow: hidden;
}
.effect-centered {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
a.layout-wrapper {
display: inline-block;
position: relative;
cursor: pointer;
width: 100vw;
height: 100vh;
overflow: hidden;
}
a.layout-wrapper img.watermark {
position: absolute;
right: 16px;
bottom: 16px;
opacity: 0.8;
transition: var(--animation-transition) ease-in-out opacity;
height: 4vw;
min-height: 20px;
}
a.layout-wrapper:hover img.watermark,
a.layout-wrapper:focus-visible img.watermark {
opacity: 1;
}
/* Layout: Error */
a.layout-wrapper div.error {
box-sizing: border-box;
border: var(--border-thickness) solid var(--background-primary);
background-image:
repeating-linear-gradient(
45deg,
transparent,
transparent 8px,
var(--background-secondary) 8px,
var(--background-secondary) 9px
),
repeating-linear-gradient(
-45deg,
transparent,
transparent 8px,
var(--background-secondary) 8px,
var(--background-secondary) 9px
);
background-color: var(--background-tertiary);
width: 100%;
height: 100%;
}
a.layout-wrapper div.error p {
border: var(--border-thickness) solid var(--background-primary);
background-color: var(--background-tertiary);
padding: 16px 32px;
width: fit-content;
color: var(--font-color-primary);
font-size: large;
font-family: monospace;
text-align: center;
}
/* Layout: Canvas */
a.layout-wrapper video.decoder {
position: absolute;
visibility: hidden;
}
a.layout-wrapper canvas.render {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Layout: Image */
a.layout-wrapper img.render {
width: 100%;
height: 100%;
object-fit: contain;
}
+269
View File
@@ -0,0 +1,269 @@
;(() => {
let defer: (() => void)[] = []
let running = false
let paramQuality: 'standard' | 'transparent'
let paramID: bigint
const elemHost = document.querySelector<HTMLAnchorElement>('a.layout-wrapper')
// @ts-expect-error
const BASE_CDN = window.__ENV__.CDN
// @ts-expect-error
const BASE_WEB = window.__ENV__.WEB
if (BASE_CDN === undefined || BASE_WEB === undefined || !elemHost) {
console.error('[gifuu] Invalid Document')
return
}
try {
const search = new URLSearchParams(window.location.search)
const givenQuality = search.get('quality')
const givenID = BigInt(search.get('id') ?? '0')
if (givenID < 1) {
return exit('Invalid ID')
}
if (givenQuality !== 'standard' && givenQuality !== 'transparent') {
return exit('Invalid Quality')
}
paramQuality = givenQuality
paramID = givenID
} catch (error) {
exit(error)
return
}
function setupGL() {
if (!elemHost) throw 'Missing Anchor Node'
// Setup Elements
const elemCanvas = document.createElement('canvas')
elemCanvas.classList.add('render')
elemCanvas.addEventListener('webglcontextlost', (ev) => {
ev.preventDefault()
console.warn('[gifuu] Failed to allocate a WebGL context for us! Using image fallback...')
teardown()
setupImage()
})
elemHost.appendChild(elemCanvas)
defer.push(() => elemCanvas.remove())
const elemVideo = document.createElement('video')
elemVideo.classList.add('decode')
elemVideo.crossOrigin = 'anonymous'
elemVideo.playsInline = true
elemVideo.autoplay = true
elemVideo.muted = true
elemVideo.loop = true
elemHost.appendChild(elemVideo)
defer.push(() => elemVideo.remove())
// Setup Context
const gl = elemCanvas.getContext('webgl', {
powerPreference: 'low-power',
premultipliedAlpha: false,
antialias: false,
alpha: true,
depth: false,
})
if (!gl) {
console.warn('[gifuu] WebGL is unsupported, using image fallback...')
teardown()
setupImage()
return
}
try {
const VERT = `
precision mediump float;
attribute vec2 aPos;
uniform mat3 uMatrix;
varying vec2 vUV;
void main() {
gl_Position = vec4(uMatrix * vec3(aPos, 1.0), 1.0);
vUV = aPos;
}`
const FRAG = `
precision mediump float;
uniform sampler2D uFrame;
varying vec2 vUV;
void main() {
vec2 colorUV = vec2(vUV.x, vUV.y * 0.5);
vec2 alphaUV = vec2(vUV.x, 0.5 + vUV.y * 0.5);
vec4 color = texture2D(uFrame, colorUV);
float alpha = texture2D(uFrame, alphaUV).r;
gl_FragColor = vec4(color.rgb, alpha);
}`
function compileShader(type: number, src: string) {
if (!gl) throw 'Missing GL Context'
const s = gl.createShader(type)
if (!s) throw 'Shader compilation failed'
gl.shaderSource(s, src)
gl.compileShader(s)
return s
}
const prog = gl.createProgram()
gl.attachShader(prog, compileShader(gl.VERTEX_SHADER, VERT))
gl.attachShader(prog, compileShader(gl.FRAGMENT_SHADER, FRAG))
gl.linkProgram(prog)
gl.useProgram(prog)
defer.push(() => gl.deleteProgram(prog))
// --- Quad ---
const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), gl.STATIC_DRAW)
defer.push(() => gl.deleteBuffer(buf))
const aPos = gl.getAttribLocation(prog, 'aPos')
gl.enableVertexAttribArray(aPos)
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0)
gl.uniformMatrix3fv(gl.getUniformLocation(prog, 'uMatrix'), false, [2, 0, 0, 0, -2, 0, -1, 1, 1])
// --- Texture ---
const tex = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.uniform1i(gl.getUniformLocation(prog, 'uFrame'), 0)
defer.push(() => gl.deleteTexture(tex))
} catch (error) {
console.error('[gifuu] Failed to Initialize WebGL dependencies:', error)
teardown()
setupImage()
return
}
// Tick Function
let cancel = 0
let sized = false
function tick() {
cancel = requestAnimationFrame(tick)
try {
if (!gl) throw 'Missing GL Context'
if (!sized && elemVideo.videoWidth > 0) {
sized = true
elemCanvas.width = elemVideo.videoWidth
elemCanvas.height = Math.floor(elemVideo.videoHeight / 2)
gl.viewport(0, 0, elemCanvas.width, elemCanvas.height)
}
if (!sized) return
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, elemVideo)
gl.drawArrays(gl.TRIANGLES, 0, 6)
} catch (error) {
console.warn('[gifuu] Draw failed, using image fallback...', error)
teardown()
setupImage()
return
}
}
defer.push(() => cancelAnimationFrame(cancel))
// Download Video
fetch(`${BASE_CDN}/${paramID}/alpha.webm`, { mode: 'cors', cache: 'force-cache' })
.then((r) => r.blob())
.then((blob) => {
const content = URL.createObjectURL(blob)
defer.push(() => URL.revokeObjectURL(content))
elemVideo.src = content
elemVideo.play().catch(() => {})
tick()
})
.catch((error) => {
console.warn('[gifuu] Video download failed, using image fallback...', error)
teardown()
setupImage()
})
}
function setupImage() {
if (!elemHost) throw 'Missing Image Node'
// Create Element
const elemImage = document.createElement('img')
elemImage.classList.add('render')
elemHost.appendChild(elemImage)
defer.push(() => elemImage.remove())
// Download Image
fetch(`${BASE_CDN}/${paramID}/${paramQuality}.avif`, { mode: 'cors', cache: 'force-cache' })
.then((r) => r.blob())
.then((blob) => {
const content = URL.createObjectURL(blob)
defer.push(() => URL.revokeObjectURL(content))
elemImage.src = content
})
.catch((error) => {
console.warn('[gifuu] Image download failed, quitting...', error)
teardown()
exit('Media Unavailable')
})
}
function teardown() {
let func
while ((func = defer.shift())) {
try {
func()
} catch (error) {
console.error('[gifuu] Teardown failed:', error)
}
}
}
function exit(error: any) {
console.error('[gifuu] Exiting Embed:', error)
teardown()
if (!elemHost) return
elemHost.href = BASE_WEB
const container = document.createElement('div')
container.classList.add('error')
const message = document.createElement('p')
message.classList.add('message', 'effect-centered')
message.textContent = String(error)
container.append(message)
elemHost.append(container)
}
// Lifecycle
elemHost.href = `${BASE_WEB}/art/${paramID}`
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !running) {
running = true
console.log('[gifuu] Showtime! Setting up...')
paramQuality === 'transparent' ? setupGL() : setupImage()
} else if (!entries[0].isIntersecting && running) {
running = false
console.log('[gifuu] Out of View! Tearing down...')
teardown()
}
},
{ threshold: 0.1 },
)
observer.observe(elemHost)
window.addEventListener('pagehide', () => {
console.log('[gifuu] Goodbye!')
observer.disconnect()
elemHost.remove()
teardown()
})
})()
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 103.11 55.18">
<path style="fill: #f0f0f0; stroke: #242424;" d="M54.36,48.13c0,3.62,2.93,6.55,6.55,6.55h22.75c6.86,0,10.07-1.56,13.62-4.68,3.55-3.12,5.33-7.66,5.33-13.61s-1.78-10.42-5.33-13.54c-3.55-3.12-6.76-4.68-13.62-4.68h-11.65c-3.62,0-6.55-2.93-6.55-6.55v-4.58c0-3.62-.93-6.55-4.55-6.55h0c-3.62,0-6.55,2.93-6.55,6.55v41.08ZM67.46,34.02c0-3.62,2.93-6.55,6.55-6.55h7.78c2.11,0,1.82.25,3.11.76,1.3.5,2.3,1.18,3.02,2.02.72.84,1.21,1.79,1.48,2.84.26,1.06.4,2.16.4,3.31s-.13,2.27-.4,3.35c-.26,1.08-.76,2.04-1.48,2.88-.72.84-1.73,1.51-3.02,2.02-1.3.5-1,.76-3.11.76h-7.78c-3.62,0-6.55-2.93-6.55-6.55v-4.82Z" />
<path style="fill: #f0f0f0; stroke: #242424;" d="M48.76,48.13c0,3.62-2.93,6.55-6.55,6.55h-22.75c-6.86,0-10.07-1.56-13.62-4.68-3.55-3.12-5.33-7.66-5.33-13.61s1.78-10.42,5.33-13.54c3.55-3.12,6.76-4.68,13.62-4.68h11.65c3.62,0,6.55-2.93,6.55-6.55v-6.58c0-2.51,2.04-4.55,4.55-4.55h0c3.62,0,6.55,2.93,6.55,6.55v41.08ZM35.65,34.02c0-3.62-2.93-6.55-6.55-6.55h-7.78c-2.11,0-1.82.25-3.11.76-1.3.5-2.3,1.18-3.02,2.02-.72.84-1.21,1.79-1.48,2.84-.26,1.06-.4,2.16-.4,3.31s.13,2.27.4,3.35c.26,1.08.76,2.04,1.48,2.88.72.84,1.73,1.51,3.02,2.02,1.3.5,1,.76,3.11.76h7.78c3.62,0,6.55-2.93,6.55-6.55v-4.82Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+567
View File
@@ -0,0 +1,567 @@
;(() => {
const elemParent = document.querySelector('div.layout-background')
const elemSprite = document.querySelector<HTMLLinkElement>('link[rel="texture"]')
if (!elemParent || !elemSprite) throw 'Invalid Document'
const FRAME_INTERVAL = 1000
const FRAME_TIME_IDLE = 12
const FRAME_TIME_ACTIVE = 60
const CAM_ACCEL = 2
const CAM_FRICTION = 0.85
const PARTICLE_COUNT = 160
const COLOR_PARTICLE = 0x484848
const COLOR_FOREGROUND = 0x363636
const COLOR_BACKGROUND = 0x000000
let keysHeld = new Set()
let frameTime = FRAME_INTERVAL / FRAME_TIME_IDLE
let lastTime = 0
let lastFrame = 0
let time = 0
let cameraMVP = mat4()
let cameraRotX = 0
let cameraRotY = 0
let camVelRotX = 0
let camVelRotY = 0
let spriteTexture: WebGLTexture
let spriteModel: Float32Array
let particlePos: Float32Array
let particleColor: Float32Array
let particleData: {
x: number
y: number
z: number
vx: number
vy: number
vz: number
life: number
maxLife: number
}[] = []
let planeW: number
let planeH: number
let planeSegX: number
let planeSegY: number
let planeVerts: Float32Array
let planeOrig: Float32Array
let planeIdx
const canvas = document.createElement('canvas')
const gl = canvas.getContext('webgl')
if (!gl) throw 'Failed to allocate WebGL Context'
// --- Camera Controls ---
document.addEventListener('keydown', (e) => {
if (!e.shiftKey && !e.ctrlKey) return
if (e.target instanceof HTMLTextAreaElement) return
if (e.target instanceof HTMLInputElement) return
if ((e as any).isContentEditable) return
keysHeld.add(e.key)
})
document.addEventListener('keyup', (e) => {
keysHeld.delete(e.key)
})
// --- Math Functions ---
function mat4(): Float32Array {
return new Float32Array(16)
}
function mat4Identity(m: Float32Array): Float32Array {
m[0] = 1
m[1] = 0
m[2] = 0
m[3] = 0
m[4] = 0
m[5] = 1
m[6] = 0
m[7] = 0
m[8] = 0
m[9] = 0
m[10] = 1
m[11] = 0
m[12] = 0
m[13] = 0
m[14] = 0
m[15] = 1
return m
}
function mat4Multiply(out: Float32Array, a: Float32Array, b: Float32Array): Float32Array {
for (let i = 0; i < 4; i++)
for (let j = 0; j < 4; j++) {
out[j * 4 + i] = 0
for (let k = 0; k < 4; k++) out[j * 4 + i] += a[k * 4 + i] * b[j * 4 + k]
}
return out
}
function mat4Perspective(m: Float32Array, fovY: number, aspect: number, near: number, far: number): Float32Array {
const f = 1.0 / Math.tan(fovY / 2)
m[0] = f / aspect
m[1] = 0
m[2] = 0
m[3] = 0
m[4] = 0
m[5] = f
m[6] = 0
m[7] = 0
m[8] = 0
m[9] = 0
m[10] = (far + near) / (near - far)
m[11] = -1
m[12] = 0
m[13] = 0
m[14] = (2 * far * near) / (near - far)
m[15] = 0
return m
}
function mat4RotateX(m: Float32Array, angle: number): Float32Array {
const c = Math.cos(angle)
const s = Math.sin(angle)
const t = mat4Identity(mat4())
t[5] = c
t[6] = s
t[9] = -s
t[10] = c
return mat4Multiply(mat4(), t, m)
}
function mat4RotateY(m: Float32Array, angle: number): Float32Array {
const c = Math.cos(angle)
const s = Math.sin(angle)
const t = mat4Identity(mat4())
t[0] = c
t[2] = -s
t[8] = s
t[10] = c
return mat4Multiply(mat4(), t, m)
}
function mat4Translate(m: Float32Array, x: number, y: number, z: number): Float32Array {
const t = mat4Identity(mat4())
t[12] = x
t[13] = y
t[14] = z
return mat4Multiply(mat4(), t, m)
}
function randFloat(lo: number, hi: number): number {
return lo + Math.random() * (hi - lo)
}
function randFloatSpread(range: number): number {
return randFloat(-range / 2, range / 2)
}
function degToRad(d: number): number {
return (d * Math.PI) / 180
}
function intToRGB(i: number): [number, number, number] {
return [((i >> 16) & 0xff) / 255, ((i >> 8) & 0xff) / 255, ((i >> 0) & 0xff) / 255]
}
// --- Prepare Shaders ---
function createShader(type: number, src: string): WebGLShader {
if (!gl) throw 'Missing Global GL Context'
const s = gl.createShader(type)
if (!s) throw 'Shader Compilation Failed'
gl.shaderSource(s, src)
gl.compileShader(s)
return s
}
function createProgram(vert: string, frag: string): WebGLProgram {
if (!gl) throw 'Missing Global GL Context'
const p = gl.createProgram()
gl.attachShader(p, createShader(gl.VERTEX_SHADER, vert))
gl.attachShader(p, createShader(gl.FRAGMENT_SHADER, frag))
gl.linkProgram(p)
return p
}
const planeProg = createProgram(
`attribute vec3 aPosition;
uniform mat4 uMVP;
uniform float uTime;
varying float vDist;
void main() {
float dist = sqrt(aPosition.x * aPosition.x + aPosition.z * aPosition.z);
float wave = sin(dist * 0.5 - uTime);
float cave = -exp(-dist * 0.1) * 3.5;
vec3 pos = vec3(aPosition.x, aPosition.y + wave + cave, aPosition.z);
vDist = length((uMVP * vec4(pos, 1.0)).xyz);
gl_Position = uMVP * vec4(pos, 1.0);
}`,
`precision mediump float;
uniform vec3 uFogColor;
uniform float uFogNear;
uniform float uFogFar;
varying float vDist;
void main() {
float fog = clamp((vDist - uFogNear) / (uFogFar - uFogNear), 0.0, 1.0);
vec3 color = mix(vec3(${intToRGB(COLOR_FOREGROUND).join(',')}), uFogColor, fog);
gl_FragColor = vec4(color, 1.0);
}`,
)
const particleProg = createProgram(
`attribute vec3 aPosition;
attribute vec4 aColor;
uniform mat4 uMVP;
varying vec4 vColor;
varying float vDist;
void main() {
vec4 pos = uMVP * vec4(aPosition, 1.0);
vDist = length(pos.xyz);
vColor = aColor;
gl_PointSize = 3.0;
gl_Position = pos;
}`,
`precision mediump float;
uniform vec3 uFogColor;
uniform float uFogNear;
uniform float uFogFar;
varying vec4 vColor;
varying float vDist;
void main() {
float fog = clamp((vDist - uFogNear) / (uFogFar - uFogNear), 0.0, 1.0);
float alpha = vColor.a * (1.0 - fog);
gl_FragColor = vec4(mix(vColor.rgb, uFogColor, fog), alpha);
}`,
)
const spriteProg = createProgram(
`attribute vec2 aPosition;
attribute vec2 aUV;
uniform mat4 uMVP;
varying vec2 vUV;
void main() {
vUV = aUV;
gl_Position = uMVP * vec4(aPosition.x , 0, aPosition.y, 1);
}`,
`precision mediump float;
uniform sampler2D uTex;
varying vec2 vUV;
void main() {
gl_FragColor = texture2D(uTex, vUV);
}`,
)
const planeBuf = gl.createBuffer()
const planeOrigBuf = gl.createBuffer()
const planeIdxBuf = gl.createBuffer()
const uTimePlane = gl.getUniformLocation(planeProg, 'uTime')
const uMVPPlane = gl.getUniformLocation(planeProg, 'uMVP')
const uFogColorPlane = gl.getUniformLocation(planeProg, 'uFogColor')
const uFogNearPlane = gl.getUniformLocation(planeProg, 'uFogNear')
const uFogFarPlane = gl.getUniformLocation(planeProg, 'uFogFar')
const particlePosBuf = gl.createBuffer()
const particleColorBuf = gl.createBuffer()
const uMVPParticle = gl.getUniformLocation(particleProg, 'uMVP')
const uFogColorParticle = gl.getUniformLocation(particleProg, 'uFogColor')
const uFogNearParticle = gl.getUniformLocation(particleProg, 'uFogNear')
const uFogFarParticle = gl.getUniformLocation(particleProg, 'uFogFar')
const spriteImage = new Image()
spriteImage.src = elemSprite.href
const spriteBuf = gl.createBuffer()
const spriteIdxBuf = gl.createBuffer()
const uMVPSprite = gl.getUniformLocation(spriteProg, 'uMVP')
const uTexSprite = gl.getUniformLocation(spriteProg, 'uTex')
function spawnParticle(i: number) {
// Put them in the center because its out of frame anyways save some resources
const x = randFloat(-planeW / 2, planeW / 2)
const z = randFloat(-planeH / 2, planeH / 2)
const l = randFloat(3.0, 6.0)
particleData[i] = {
x,
y: 0,
z,
vx: randFloatSpread(0.05),
vy: randFloat(0.02, 0.05),
vz: randFloatSpread(0.05),
life: l,
maxLife: l,
}
const p = i * 3
particlePos[p + 0] = x
particlePos[p + 1] = 0
particlePos[p + 2] = z
const c = i * 4
const [r, g, b] = intToRGB(COLOR_PARTICLE)
particleColor[c + 0] = r
particleColor[c + 1] = g
particleColor[c + 2] = b
particleColor[c + 3] = 0
}
function startup() {
if (!gl) throw 'Missing Global GL Context'
// Render Resolution
canvas.width = Math.floor(window.innerWidth * 0.3)
canvas.height = Math.floor(window.innerHeight * 0.3)
canvas.style.width = window.innerWidth + 'px'
canvas.style.height = window.innerHeight + 'px'
canvas.style.imageRendering = 'pixelated'
// Build Sprite
spriteImage.onload = () => {
gl.bindBuffer(gl.ARRAY_BUFFER, spriteBuf)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, 1, 1, -1, 0, 1, 1, 1, 0, 0, -1, 1, 1, 0]),
gl.STATIC_DRAW,
)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, spriteIdxBuf)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW)
spriteModel = mat4Identity(mat4())
spriteModel = mat4RotateY(spriteModel, degToRad(180))
spriteModel = mat4Translate(spriteModel, 0, 4, 16)
// Upload Texture
spriteTexture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, spriteTexture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, spriteImage)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.useProgram(spriteProg)
gl.uniform1i(uTexSprite, 0)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, spriteTexture)
gl.bindBuffer(gl.ARRAY_BUFFER, spriteBuf)
}
// Build Plane
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(...intToRGB(COLOR_BACKGROUND), 1)
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
const ratio = canvas.width / canvas.height
const scale = 24
planeSegX = Math.round(12 * ratio)
planeSegY = 12
planeW = scale * ratio
planeH = scale
const nx = planeSegX + 1
const ny = planeSegY + 1
planeVerts = new Float32Array(nx * ny * 3)
planeOrig = new Float32Array(nx * ny * 3)
let vi = 0
for (let iy = 0; iy < ny; iy++) {
for (let ix = 0; ix < nx; ix++) {
const x = (ix / planeSegX - 0.5) * planeW
const z = (iy / planeSegY - 0.5) * planeH
planeVerts[vi] = x
planeVerts[vi + 1] = 0
planeVerts[vi + 2] = z
planeOrig[vi] = x
planeOrig[vi + 1] = 0
planeOrig[vi + 2] = z
vi += 3
}
}
const lines = []
for (let iy = 0; iy < ny; iy++) {
for (let ix = 0; ix < nx; ix++) {
const idx = iy * nx + ix
// wireframe indices two triangles per quad
if (ix < planeSegX) {
lines.push(idx, idx + 1)
}
if (iy < planeSegY) {
lines.push(idx, idx + nx)
}
if (ix < planeSegX && iy < planeSegY) {
lines.push(idx, idx + nx + 1)
}
}
}
planeIdx = new Uint16Array(lines)
// Upload plane buffers
gl.bindBuffer(gl.ARRAY_BUFFER, planeBuf)
gl.bufferData(gl.ARRAY_BUFFER, planeVerts, gl.DYNAMIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, planeOrigBuf)
gl.bufferData(gl.ARRAY_BUFFER, planeOrig, gl.STATIC_DRAW)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, planeIdxBuf)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, planeIdx, gl.STATIC_DRAW)
// Initialize particles if first time
if (particleData.length === 0) {
particlePos = new Float32Array(PARTICLE_COUNT * 3)
particleColor = new Float32Array(PARTICLE_COUNT * 4)
for (let i = 0; i < PARTICLE_COUNT; i++) {
spawnParticle(i)
}
}
gl.bindBuffer(gl.ARRAY_BUFFER, particlePosBuf)
gl.bufferData(gl.ARRAY_BUFFER, particlePos, gl.DYNAMIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, particleColorBuf)
gl.bufferData(gl.ARRAY_BUFFER, particleColor, gl.DYNAMIC_DRAW)
// Initialize Fog
gl.useProgram(planeProg)
gl.uniform3f(uFogColorPlane, ...intToRGB(COLOR_BACKGROUND))
gl.uniform1f(uFogNearPlane, 2)
gl.uniform1f(uFogFarPlane, 22)
gl.useProgram(particleProg)
gl.uniformMatrix4fv(uMVPParticle, false, cameraMVP)
gl.uniform3f(uFogColorParticle, ...intToRGB(COLOR_BACKGROUND))
gl.uniform1f(uFogNearParticle, 6)
gl.uniform1f(uFogFarParticle, 24)
// Force dirty camera reset
camVelRotY = 0.005
}
function animate(now: number) {
requestAnimationFrame(animate)
// Sleep (warning this sucks)
if (now - lastFrame < frameTime) return
const delta = (now - lastTime) * 0.00008 || 0
lastFrame = now
lastTime = now
time += delta
if (!gl) throw 'Missing Global GL Context'
gl.clear(gl.COLOR_BUFFER_BIT)
// Camera Movement
if (keysHeld.has('ArrowLeft')) camVelRotY -= CAM_ACCEL
if (keysHeld.has('ArrowRight')) camVelRotY += CAM_ACCEL
if (keysHeld.has('ArrowUp')) camVelRotX -= CAM_ACCEL
if (keysHeld.has('ArrowDown')) camVelRotX += CAM_ACCEL + 8
camVelRotX *= CAM_FRICTION
camVelRotY *= CAM_FRICTION
const dirty = Math.abs(camVelRotX) + Math.abs(camVelRotY) > 0.001
if (dirty) {
cameraRotX += camVelRotX * delta * 1000
cameraRotY += camVelRotY * delta * 1000
// Update Camera
const ratio = canvas.width / canvas.height
const proj = mat4Perspective(mat4(), degToRad(70), ratio, 0.1, 100)
let view = mat4Identity(mat4())
view = mat4Translate(view, 0, -7.5, -15)
view = mat4RotateX(view, degToRad(camVelRotX + 33.75))
view = mat4RotateY(view, degToRad(camVelRotY))
cameraMVP = mat4Multiply(mat4(), proj, view)
gl.useProgram(planeProg)
gl.uniformMatrix4fv(uMVPPlane, false, cameraMVP)
gl.useProgram(particleProg)
gl.uniformMatrix4fv(uMVPParticle, false, cameraMVP)
frameTime = FRAME_INTERVAL / FRAME_TIME_ACTIVE
} else {
frameTime = FRAME_INTERVAL / FRAME_TIME_IDLE
}
// Update Sprite
if (spriteTexture) {
const spriteMVP = mat4Multiply(mat4(), cameraMVP, spriteModel)
gl.useProgram(spriteProg)
gl.uniformMatrix4fv(uMVPSprite, false, spriteMVP)
gl.uniform1i(uTexSprite, 0)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, spriteTexture)
gl.bindBuffer(gl.ARRAY_BUFFER, spriteBuf)
const aPos = gl.getAttribLocation(spriteProg, 'aPosition')
const aUV = gl.getAttribLocation(spriteProg, 'aUV')
gl.enableVertexAttribArray(aPos)
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 16, 0)
gl.enableVertexAttribArray(aUV)
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 16, 8)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, spriteIdxBuf)
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0)
}
{
// Update Particles
for (let i = 0; i < PARTICLE_COUNT; i++) {
const p = particleData[i]
// fade out
p.life -= delta
if (p.life <= 0) {
spawnParticle(i)
continue
}
particleColor[i * 4 + 3] = Math.min((p.life / p.maxLife) * 1.2, 1)
// drift away
p.x += p.vx * delta * 40
p.y += p.vy * delta * 20
p.z += p.vz * delta * 40
const pi = i * 3
particlePos[pi + 0] = p.x
particlePos[pi + 1] = p.y
particlePos[pi + 2] = p.z
}
gl.bindBuffer(gl.ARRAY_BUFFER, particlePosBuf)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, particlePos)
gl.bindBuffer(gl.ARRAY_BUFFER, particleColorBuf)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, particleColor)
// Draw Particles
gl.useProgram(particleProg)
const aPos = gl.getAttribLocation(particleProg, 'aPosition')
const aCol = gl.getAttribLocation(particleProg, 'aColor')
gl.bindBuffer(gl.ARRAY_BUFFER, particlePosBuf)
gl.enableVertexAttribArray(aPos)
gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, particleColorBuf)
gl.enableVertexAttribArray(aCol)
gl.vertexAttribPointer(aCol, 4, gl.FLOAT, false, 0, 0)
gl.drawArrays(gl.POINTS, 0, PARTICLE_COUNT)
}
{
// Update Plane
for (let i = 0; i < planeVerts.length; i += 3) {
const x = planeOrig[i]
const z = planeOrig[i + 2]
const dist = Math.sqrt(x * x + z * z)
planeVerts[i] = x
planeVerts[i + 1] = Math.sin(dist * 0.5 - time) * 0.5 + -Math.exp(-dist * 0.1) * 3.5
planeVerts[i + 2] = z
}
gl.bindBuffer(gl.ARRAY_BUFFER, planeBuf)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, planeVerts)
// Draw Plane
gl.useProgram(planeProg)
gl.uniform1f(uTimePlane, time)
const aPos = gl.getAttribLocation(planeProg, 'aPosition')
gl.bindBuffer(gl.ARRAY_BUFFER, planeBuf)
gl.enableVertexAttribArray(aPos)
gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, planeIdxBuf)
gl.drawElements(gl.LINES, planeIdx.length, gl.UNSIGNED_SHORT, 0)
}
}
// --- Prepare Canvas ---
window.addEventListener('resize', startup)
startup()
animate(0)
elemParent.append(canvas)
})()
+209
View File
@@ -0,0 +1,209 @@
:root {
--animation-duration: 500ms;
--animation-transition: 200ms;
--animation-load-delay: 200ms;
--animation-step-delay: 50ms;
--border-thickness: 1px;
--background-tertiary: hsl(0, 0%, 0%);
--background-secondary: hsl(0, 0%, 16%);
--background-primary: hsl(0, 0%, 32%);
--background-highlight: hsl(0, 0%, 80%);
--background-translucent: hsla(0, 0%, 0%, 0.3);
--font-color-accent: hsl(0, 50%, 80%);
--font-color-primary: hsl(0, 0%, 95%);
--font-color-secondary: hsl(0, 0%, 65%);
--effect-glass-corner-thickness: 1px;
--effect-glass-corner-offset: -16px;
--effect-glass-corner-margin: 16px;
--effect-glass-corner-color: hsl(0, 0%, 30%);
--effect-glass-tint: hsla(0, 0%, 100%, 0.075);
--effect-glass-blur: 4px;
}
::-webkit-scrollbar {
width: 0;
}
html,
body {
margin: 0;
background-color: var(--background-tertiary);
padding: 0;
}
p,
a,
pre,
code,
span,
input,
textarea,
label,
button {
display: block;
margin: 0;
border: none;
background-color: transparent;
padding: 0;
color: var(--font-color-primary);
font-style: normal;
font-weight: normal;
font-size: 1em;
line-height: 1em;
font-family: 'Terminus', monospace;
}
input::placeholder {
color: var(--font-color-secondary);
}
/* Global Layout */
span.layout-tooltip {
border: var(--border-thickness) solid var(--background-primary);
background-color: var(--background-tertiary);
padding: 4px 8px;
}
div.layout-wrapper {
display: grid;
width: 100%;
height: 100%;
}
div.layout-background {
position: fixed;
width: 100vw;
height: 100vh;
}
div.layout-foreground {
display: flex;
justify-content: center;
gap: 16px;
margin: auto;
margin-top: 16px;
width: 100%;
max-width: 1024px;
max-height: 100vh;
}
.layout-scrolling {
max-height: calc(100vh - var(--effect-glass-corner-margin) * 4);
overflow-x: hidden;
overflow-y: scroll;
}
nav.layout-sidebar {
box-sizing: border-box;
padding: 16px;
width: 300px;
}
main.layout-content {
box-sizing: border-box;
padding: 16px;
width: 600px;
}
/* Global Effects */
.animation-blink {
animation: kf-blink 1s infinite step-start;
}
@keyframes kf-blink {
50% {
opacity: 0;
}
}
.animation-scroll-in {
animation: kf-scroll-in 1s forwards linear;
box-sizing: border-box;
max-width: fit-content;
overflow: hidden;
text-wrap-mode: nowrap;
}
@keyframes kf-scroll-in {
0% {
width: 0%;
}
100% {
width: 100%;
}
}
.animation-fade-in {
animation: kf-fade-in 500ms forwards linear;
}
@keyframes kf-fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.animation-fall-in {
opacity: 0;
animation: kf-fall-in var(--animation-transition) ease forwards;
}
@keyframes kf-fall-in {
from {
transform: scale(1.05);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.animation-caution {
position: relative;
overflow: visible;
}
.animation-caution::before {
position: absolute;
transform: translate(16px, 16px);
clip-path: polygon(
calc(100% - 12px) 0,
100% 0,
100% 100%,
0 100%,
0 calc(100% - 12px),
calc(100% - 12px) calc(100% - 12px)
);
filter: opacity(0.33);
animation: kf-caution 1200s infinite linear;
box-sizing: border-box;
inset: 0;
background: repeating-linear-gradient(
45deg,
var(--background-primary) 0,
var(--background-primary) 8px,
black 8px,
black 16px
);
content: '';
}
@keyframes kf-caution {
0% {
background-position-y: 0px;
}
100% {
background-position-y: 7200px;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 B