commit 1660bb9f6528f55856dc5bd3c9508164ce6dea2f Author: mid Date: Tue Aug 18 09:57:55 2026 +0800 Initial homepage diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..be00634 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.woff2 binary +*.zip binary diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..f7bc7c0 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,20 @@ +name: Verify + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + production-checks: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Run production checks + shell: pwsh + run: .\scripts\verify-production.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c5aa8b --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Local and release artifacts +*.zip +*.sha256 +*.log + +# Secrets and certificates +.env +.env.* +!.env.example +*.pem +*.key +*.crt +*.cer +*.p12 +*.pfx + +# Host-specific files +backups/ +certificate-check/ +work/ +outputs/ + +# Editors and operating systems +.vscode/ +.idea/ +.DS_Store +Thumbs.db +desktop.ini + +# Runtime caches +node_modules/ +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cd3bccd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# AGENTS.md + +本文件适用于整个仓库。 + +## 开始工作前 + +1. 阅读 `README.md` 和 `docs/AI-HANDOFF.md`。 +2. 运行 `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-production.ps1`。 +3. 检查工作区已有改动,不要覆盖不属于当前任务的修改。 + +## 必须保持的行为 + +- Sakura 保持 1,600 粒子、五次花瓣绘制、四次 Bloom 和 120 FPS 上限,除非用户明确批准降低效果。 +- 动画使用 `requestAnimationFrame`,页面隐藏时暂停,页面恢复时只能存在一个动画循环。 +- 保留 cold-start、`pagehide/pageshow`、WebGL context recovery 和 Render Target fallback 测试。 +- Blog 和 Tools 默认在新标签页打开。 +- `site/assets/js/site-config.json` 失败时必须保留 HTML 后备链接。 +- reduced-motion 当前会让 WebGL 使用静态帧;改变该策略前先与用户确认。 +- `site/404.html` 使用根绝对资源路径,避免嵌套 URL 下资源失效。 + +## 修改规则 + +- 网站源文件位于 `site/`,不要创建第二份重复的运行副本。 +- 修改 CSS 或 JavaScript 后同步更新 `site/index.html` 和 `site/404.html` 的资源查询版本。 +- 修改字体文件后更改字体 URL 版本或文件名,因为生产环境使用长期缓存。 +- 不要将密钥、证书私钥、访问令牌、主机 IP、私有代理路径或服务器备份写入仓库。 +- `nginx/midctrl.com.example.conf` 只能保持为脱敏示例;部署时合并现有主机私有 location,不要在公开仓库记录它们。 +- 不要将测试工具、备份或发布 ZIP 放进 `site/`。 + +## 完成标准 + +- 生产检查全部通过。 +- 首页和 404 页面控制台无错误。 +- 桌面端和移动端无横向溢出或元素重叠。 +- 连续画面采样确认 Sakura 在非 reduced-motion 的前台页面持续更新。 +- 若生成发布包,ZIP 只能包含 `site/` 文件,并生成 SHA-256。 +- 未经用户明确授权,不连接生产服务器、不上传文件、不 reload Nginx。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..612b078 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# 中枢首页 + +`midctrl.com` 的纯静态导航首页。页面不需要构建工具、后端运行时或数据库,Nginx 可以直接提供 `site/` 中的文件。 + +## 功能 + +- WebGL Sakura 背景,1,600 个粒子、五次花瓣绘制、四次 Bloom,最高 120 FPS。 +- DOM 就绪后立即初始化,不等待字体或图片完成加载。 +- 页面进入后台时暂停,通过 `visibilitychange` 和 `pageshow` 恢复。 +- WebGL context 丢失、Render Target resize 和显存分配失败恢复。 +- 自托管 Noto Serif SC 字体、圆形头像框、标题与文案效果。 +- 外链由 JSON 配置,并在新标签页打开。 +- 响应式首页和 404 页面。 + +## 本地预览 + +```powershell +python -m http.server 9010 --bind 127.0.0.1 --directory site +``` + +打开 `http://127.0.0.1:9010/`。 + +## 修改站点与导航 + +编辑 `site/assets/js/site-config.json`。站点名称、描述、规范 URL 和导航链接都集中在这里: + +```json +{ + "site": { + "name": "中枢", + "description": "你也可以变成光", + "url": "https://midctrl.com" + }, + "links": [ + { "name": "Blog", "url": "https://wp.midctrl.com" }, + { "name": "Tools", "url": "https://tools.midctrl.com" } + ] +} +``` + +HTML 中保留了同样的后备链接,这是 JSON 请求失败、JavaScript 被禁用或网络异常时的故障降级;修改 `links` 后需同步更新 `site/index.html` 中的后备锚点。JSON 请求失败或超过三秒时,后备链接仍可使用,WebGL 初始化不受影响。 + +页面运行时会从同一份配置更新标题、canonical 和 Open Graph URL,避免在 HTML 与 JavaScript 中重复维护站点域名。`robots.txt` 和 `sitemap.xml` 中的绝对 URL 是搜索引擎协议要求的静态发布文件;更换域名时应同步更新这两个文件。 + +## 验证 + +Windows PowerShell: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-production.ps1 +``` + +检查内容包括 JavaScript 语法、冷启动、页面恢复、单动画循环、WebGL context 恢复、Render Target resize、渲染质量参数、字体许可、Nginx 示例和公开目录文件白名单。GitHub Actions 会在 push 和 pull request 时运行相同检查。 + +## 目录 + +- `site/`:可直接部署的网站文件。 +- `tests/`:WebGL 生命周期和 Render Target 测试。 +- `scripts/`:生产检查脚本。 +- `nginx/`:不包含主机私有 location 的公开配置示例。 +- `docs/`:部署和 AI 交接说明。 +- `AGENTS.md`:后续 AI 会话应遵守的项目约束。 + +## 部署 + +参见 [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)。部署时只复制 `site/` 的内容,不要把仓库文档、测试或 Git 元数据放入网站根目录。 + +## 前端代码与安全 + +浏览器必须下载前端代码和资源才能渲染,因此无法阻止访问者检查或复制 HTML、CSS、JavaScript、Shader 和图片。混淆只能增加阅读成本,不是安全边界。仓库中不得提交密钥、令牌、源站证书私钥或主机私有代理配置。 + +## 许可 + +项目当前没有声明整体开源许可证;公开仓库默认不向第三方授予复制、修改或再发布权利。自托管 Noto Serif SC 子集仍受 `site/assets/fonts/OFL.txt` 中的 SIL Open Font License 约束。若计划把项目作为可复用开源项目发布,应先明确选择项目级许可证。 + +## AI 继续工作 + +新的 AI 会话应先阅读 [AGENTS.md](AGENTS.md) 和 [docs/AI-HANDOFF.md](docs/AI-HANDOFF.md),再运行生产检查,避免丢失渲染质量和部署边界。 diff --git a/docs/AI-HANDOFF.md b/docs/AI-HANDOFF.md new file mode 100644 index 0000000..2588d52 --- /dev/null +++ b/docs/AI-HANDOFF.md @@ -0,0 +1,63 @@ +# AI 交接说明 + +更新日期:2026-08-18 + +## 当前状态 + +项目是 `midctrl.com` 的纯静态首页,无数据库、API 或构建步骤。网站运行文件位于 `site/`。当前版本已完成代码复审、冷启动修复、移动端检查和公开文件白名单验证,但本仓库版本尚未上传生产服务器。 + +## 最近完成的修复 + +- WebGL 从等待 `window.load` 改为 DOM 就绪后初始化,字体或图片请求不会阻塞 Sakura。 +- 初始化函数幂等,避免重复创建场景。 +- 初始化时重新读取页面可见性和 reduced-motion 状态。 +- `pagehide` 取消待执行帧,`pageshow` 重新同步并恢复。 +- generation guard 隔离浏览器队列中未及时移除的旧 rAF 回调。 +- `site-config.json` 请求超时为三秒,失败时使用 HTML 中的链接。 +- 资源查询版本为 `20260818-3`。 + +## 渲染约束 + +- 粒子数:1,600。 +- 花瓣 draw call:五次。 +- Bloom directional blur draw:四次。 +- 帧率上限:120 FPS,跟随显示器 `requestAnimationFrame`。 +- 内部像素预算:普通设备 1,000 万像素,浏览器报告内存不高于 4 GB 时为 600 万像素。 +- resize 稳定窗口:140 ms。 +- reduced-motion:WebGL 静态帧,头像框使用静态图片。 + +不要在没有用户明确批准的情况下通过降低粒子、Bloom 或绘制次数解决性能问题。 + +## 关键文件 + +- `site/assets/js/main.js`:链接配置、Shader、Sakura、Render Target 和生命周期。 +- `site/assets/css/main.css`:布局、字体、文字和头像效果。 +- `site/assets/js/site-config.json`:站点名称、描述、规范 URL、外链名称和 URL。 +- `tests/test-render-target-resize.js`:无第三方依赖的 VM 测试。 +- `scripts/verify-production.ps1`:生产检查和公开目录白名单。 +- `nginx/midctrl.com.example.conf`:脱敏示例,不是生产服务器完整配置。 + +## 推荐工作流程 + +```powershell +python -m http.server 9010 --bind 127.0.0.1 --directory site +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-production.ps1 +``` + +修改后先运行自动检查,再用真实浏览器检查首页、移动端和 `/404.html`。检查控制台并对纯 Sakura 区域间隔采样,确认画面确实变化。 + +## 生产边界 + +- Cloudflare SSL/TLS 使用 `Full (strict)`。 +- 源站为 AlmaLinux 9 和 Nginx。 +- 生产主机还有不属于本仓库的私有 location,部署时必须从现有服务器配置中保留。 +- 证书私钥、服务器地址、私有 location、备份和上传凭据不得进入仓库。 +- 用户授权部署前,只能在本地修改和验证。 + +## 新会话启动提示 + +可以在新 AI 会话中直接说明:“请先阅读 `AGENTS.md` 和 `docs/AI-HANDOFF.md`,运行生产检查,再继续处理我的需求。” + +## 域名配置边界 + +运行时站点身份从 `site/assets/js/site-config.json` 读取,首页的标题、canonical 和 Open Graph 元数据不会再分别写死域名。`site/robots.txt` 与 `site/sitemap.xml` 是需要绝对 URL 的静态 SEO 文件;更换站点域名时必须一并更新它们,并运行生产检查。 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..d7c0db5 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,72 @@ +# 部署说明 + +## 环境 + +- AlmaLinux 9 +- Nginx +- Cloudflare CDN +- Cloudflare SSL/TLS:`Full (strict)` + +网站是纯静态文件,不需要数据库、Node.js 服务或常驻应用进程。 + +## 生成发布包 + +在仓库根目录执行: + +```powershell +Compress-Archive -Path .\site\* -DestinationPath .\midctrl-homepage-public.zip -CompressionLevel Optimal +Get-FileHash .\midctrl-homepage-public.zip -Algorithm SHA256 +``` + +发布 ZIP 不应提交到 Git。 + +## 部署网站文件 + +将 ZIP 上传后解压到独立网站目录: + +```bash +mkdir -p /var/www/midctrl.com +unzip -o /root/midctrl-homepage-public.zip -d /var/www/midctrl.com +chown -R root:nginx /var/www/midctrl.com +find /var/www/midctrl.com -type d -exec chmod 755 {} \; +find /var/www/midctrl.com -type f -exec chmod 644 {} \; +``` + +如果 SELinux 已启用,再执行: + +```bash +restorecon -Rv /var/www/midctrl.com +``` + +## Nginx + +`nginx/midctrl.com.example.conf` 是公开、脱敏的参考模板。不要直接覆盖生产配置。部署时需要: + +1. 将现有站点 `root` 指向 `/var/www/midctrl.com`。 +2. 合并安全响应头、缓存和 gzip 规则。 +3. 保留服务器现有的所有私有 location 和代理请求头。 +4. 保留有效的 Cloudflare Origin Certificate 路径。 +5. 确认字体使用 `font/woff2` 或兼容的 MIME 类型。 + +生效前必须执行: + +```bash +nginx -t +systemctl reload nginx +``` + +只有 `nginx -t` 成功后才能 reload。 + +## 部署后检查 + +- 首页和 404 页面正常。 +- Blog 与 Tools 在新标签页打开。 +- Noto Serif SC 字体返回 200。 +- 控制台无 JavaScript、WebGL 或资源错误。 +- Sakura 在前台页面持续运动,页面进入后台后暂停并能恢复。 +- 服务器原有私有服务仍正常。 +- 仓库 README、测试和 Nginx 示例没有出现在网站根目录。 + +## 回滚 + +部署前备份当前网站目录和 Nginx 配置。发生问题时恢复备份,运行 `nginx -t`,成功后 reload。不要把生产备份提交到 Git。 diff --git a/nginx/midctrl.com.example.conf b/nginx/midctrl.com.example.conf new file mode 100644 index 0000000..04f0c5b --- /dev/null +++ b/nginx/midctrl.com.example.conf @@ -0,0 +1,76 @@ +server { + listen 80; + listen [::]:80; + server_name midctrl.com www.midctrl.com; + + return 301 https://midctrl.com$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name www.midctrl.com; + + ssl_certificate /etc/ssl/cloudflare/cert.pem; + ssl_certificate_key /etc/ssl/cloudflare/key.pem; + + return 301 https://midctrl.com$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name midctrl.com; + + root /var/www/midctrl.com; + index index.html; + + ssl_certificate /etc/ssl/cloudflare/cert.pem; + ssl_certificate_key /etc/ssl/cloudflare/key.pem; + + charset utf-8; + server_tokens off; + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'none'; upgrade-insecure-requests" always; + + location = /index.html { + expires -1; + try_files $uri =404; + } + + location ~* ^/assets/(?:css|js)/.+\.(?:css|js|json)$ { + expires -1; + try_files $uri =404; + } + + location ^~ /assets/images/ { + expires 7d; + try_files $uri =404; + } + + location ^~ /assets/fonts/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Merge host-private application locations from the existing server + # configuration here. Do not store their paths or upstreams in Git. + + location / { + try_files $uri $uri/ =404; + } + + error_page 404 /404.html; + location = /404.html { + internal; + } + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css application/javascript application/json application/manifest+json image/svg+xml application/xml; +} diff --git a/scripts/verify-production.ps1 b/scripts/verify-production.ps1 new file mode 100644 index 0000000..b100a89 --- /dev/null +++ b/scripts/verify-production.ps1 @@ -0,0 +1,151 @@ +param( + [string]$Root = (Join-Path $PSScriptRoot "..\site"), + [string]$PublicRoot = "", + [string]$PublicArchive = "" +) + +$ErrorActionPreference = "Stop" +$Root = (Resolve-Path -LiteralPath $Root).Path + +function Assert-Check { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw "FAIL: $Message" } + Write-Host "PASS: $Message" +} + +$requiredFiles = @( + "index.html", + "404.html", + "robots.txt", + "sitemap.xml", + "site.webmanifest", + "assets/css/main.css", + "assets/js/main.js", + "assets/js/site-config.json", + "assets/fonts/NotoSerifSC-subset.woff2", + "assets/fonts/OFL.txt", + "assets/images/avatar.jpg", + "assets/images/avatar-frame-animated.png", + "assets/images/avatar-frame-mask.svg", + "assets/images/avatar-frame-static.png", + "assets/images/favicon.svg" +) + +foreach ($relative in $requiredFiles) { + Assert-Check (Test-Path -LiteralPath (Join-Path $Root $relative) -PathType Leaf) "required file exists: $relative" +} + +$index = Get-Content -Raw -Encoding UTF8 (Join-Path $Root "index.html") +$notFound = Get-Content -Raw -Encoding UTF8 (Join-Path $Root "404.html") +$manifestPath = Join-Path $Root "site.webmanifest" +$manifest = Get-Content -Raw -Encoding UTF8 $manifestPath | ConvertFrom-Json +$siteConfig = Get-Content -Raw -Encoding UTF8 (Join-Path $Root "assets/js/site-config.json") | ConvertFrom-Json +[Uri]$siteUri = $siteConfig.site.url +[string]$siteRoot = $siteUri.AbsoluteUri.TrimEnd('/') + '/' +$robots = Get-Content -Raw -Encoding UTF8 (Join-Path $Root "robots.txt") +[xml]$sitemap = Get-Content -Raw -Encoding UTF8 (Join-Path $Root "sitemap.xml") +[xml]$favicon = Get-Content -Raw -Encoding UTF8 (Join-Path $Root "assets/images/favicon.svg") +$nginxPath = Join-Path $PSScriptRoot "..\nginx\midctrl.com.example.conf" +Assert-Check (Test-Path -LiteralPath $nginxPath -PathType Leaf) "sanitized Nginx example exists" +$nginx = Get-Content -Raw -Encoding UTF8 $nginxPath +$css = Get-Content -Raw -Encoding UTF8 (Join-Path $Root "assets/css/main.css") +$jsPath = Join-Path $Root "assets/js/main.js" +$js = Get-Content -Raw -Encoding UTF8 $jsPath +$indexScriptVersion = [regex]::Match($index, 'main\.js\?v=([^"'']+)').Groups[1].Value +$notFoundScriptVersion = [regex]::Match($notFound, 'main\.js\?v=([^"'']+)').Groups[1].Value +$fontVersion = [regex]::Match($css, 'NotoSerifSC-subset\.woff2\?v=([^"'']+)').Groups[1].Value + +Assert-Check ($notFound -match 'href="/assets/images/avatar\.jpg"' -and $notFound -match 'href="/assets/css/main\.css(?:\?[^" ]*)?"' -and $notFound -match 'src="/assets/js/main\.js(?:\?[^" ]*)?"' -and $notFound -match 'src="/assets/images/avatar\.jpg"') "404 assets use root-absolute URLs" +Assert-Check ($notFound -match 'class="portrait-image"' -and $notFound -match 'avatar-frame-animated\.png' -and $notFound -notmatch 'axis-line') "404 page uses the current avatar structure" +Assert-Check ($manifest.icons[0].src -eq "/assets/images/avatar.jpg") "manifest icon uses root-absolute URL" +Assert-Check ($manifest.icons[0].sizes -eq "512x512") "manifest icon size matches the avatar" +Assert-Check ([string]$manifest.start_url -eq "/") "manifest starts at the site root" +Assert-Check ($siteConfig.site.name -and $siteConfig.site.description -and $siteUri.IsAbsoluteUri -and $siteUri.Scheme -eq "https") "site identity and HTTPS URL are centralized in site-config.json" +Assert-Check ($siteConfig.links.Count -gt 0 -and @($siteConfig.links | Where-Object { -not $_.name -or $_.url -notmatch '^https://' }).Count -eq 0) "navigation configuration contains named HTTPS links" +Assert-Check ([string]$sitemap.urlset.url.loc -eq $siteRoot -and $robots -match [regex]::Escape("Sitemap: ${siteRoot}sitemap.xml")) "robots and sitemap use the configured canonical homepage" +Assert-Check ($index -match 'rel="canonical" href="/"' -and $index -match 'property="og:url" content="/"' -and $js -match 'applySiteMetadata') "HTML keeps portable metadata fallbacks and applies the configured site URL" +Assert-Check ([string]$favicon.svg.viewBox -eq "0 0 64 64") "favicon is valid SVG XML" + +Add-Type -AssemblyName System.Drawing +$avatar = [System.Drawing.Image]::FromFile((Join-Path $Root "assets/images/avatar.jpg")) +try { + Assert-Check ($avatar.Width -eq 512 -and $avatar.Height -eq 512) "avatar is 512x512" +} finally { + $avatar.Dispose() +} + +$node = Get-Command node -ErrorAction SilentlyContinue +Assert-Check ($null -ne $node) "Node.js is available for JavaScript syntax validation" +& $node.Source --check $jsPath +Assert-Check ($LASTEXITCODE -eq 0) "main.js passes node --check" + +$renderTargetTest = Join-Path $PSScriptRoot "..\tests\test-render-target-resize.js" +& $node.Source $renderTargetTest $jsPath +Assert-Check ($LASTEXITCODE -eq 0) "Render Target resize and recovery tests pass" + +Assert-Check ($js -match 'pointFlower\.numFlowers\s*=\s*1600') "particle count remains 1600" +Assert-Check (([regex]::Matches($js, 'gl\.drawArrays\(gl\.POINTS')).Count -eq 5) "flower draw call count remains 5" +Assert-Check ($js -match 'for\(var i = 0; i < 2; i\+\+\)' -and ([regex]::Matches($js, 'drawEffect\(effectLib\.dirBlur\)').Count -eq 2)) "Bloom retains four directional blur draws" +Assert-Check ($js -match 'maximumFrameRate\s*=\s*120') "animation cap remains 120 FPS" +Assert-Check ($js -match 'requestAnimationFrame' -and $js -match 'matchMedia\(' -and $js -match 'prefers-reduced-motion') "refresh scheduling and reduced-motion handling are present" +Assert-Check ($js -match 'DOMContentLoaded' -and $js -match 'initializeSakura' -and $js -notmatch "window\.addEventListener\('load'") "WebGL starts when the DOM is ready without waiting for fonts or images" +Assert-Check ($js -match "addEventListener\('pagehide'" -and $js -match "addEventListener\('pageshow'" -and $js -match 'synchronizeAnimationState') "page lifecycle recovery re-synchronizes the animation loop" +Assert-Check ($js -match 'request\.timeout\s*=\s*3000') "navigation configuration has a bounded network timeout" +Assert-Check ($indexScriptVersion -and $indexScriptVersion -eq $notFoundScriptVersion -and $indexScriptVersion -eq $fontVersion) "homepage, 404 page, and font use one cache version" +Assert-Check ($js -match 'createRenderTargetSet' -and $js -match 'deleteRenderTargetSet') "render target sets are swapped atomically" +Assert-Check ($js -match 'normalRenderPixelBudget\s*=\s*10000000' -and $js -match 'constrainedRenderPixelBudget\s*=\s*6000000') "render target pixel budgets are configured" +Assert-Check ($js -match 'resizeDebounceMs\s*=\s*140' -and $js -match 'measureCanvasSize' -and $js -match 'commitCanvasSize') "resize measurement and delayed commit are present" +Assert-Check ($js -match 'shouldReleaseBeforeAllocate' -and $js -match 'gl\.finish\(\)' -and $js -match 'getResizeCandidates') "high-memory resize and allocation fallback paths are present" +Assert-Check ($js -notmatch 'gl\.enable\(gl\.TEXTURE_2D\)' -and $js -notmatch 'gl\.disable\(gl\.TEXTURE_2D\)' -and $js -notmatch 'gl\.POINT\b') "invalid WebGL capability enums are absent" +Assert-Check ($nginx -match 'root /var/www/midctrl\.com;' -and $nginx -match 'try_files \$uri \$uri/ =404') "Nginx serves only the dedicated static web root" +Assert-Check ($nginx -match 'location ~\* \^/assets/\(\?:css\|js\)/' -and $nginx -match 'expires -1') "unversioned CSS and JavaScript revalidate" +Assert-Check ($nginx -match 'location \^~ /assets/fonts/' -and $nginx -match 'Cache-Control "public, immutable"') "self-hosted fonts use long-lived immutable caching" +Assert-Check ($nginx -notmatch 'location\s+/[a-f0-9]{16,}' -and $nginx -notmatch 'proxy_pass\s+http://127\.0\.0\.1:\d+') "public Nginx example omits host-private proxy details" +Assert-Check ($js -match 'frameRequestGeneration' -and $js -match 'cancelScheduledAnimationFrame') "animation loop guards stale callbacks after context loss" +Assert-Check ((Get-Item -LiteralPath (Join-Path $Root "assets/fonts/NotoSerifSC-subset.woff2")).Length -gt 1000 -and (Get-Content -Raw -Encoding UTF8 (Join-Path $Root "assets/fonts/OFL.txt")) -match 'SIL Open Font License') "Noto Serif SC subset and license are present" + +$allowedPublic = @( + "index.html", + "404.html", + "robots.txt", + "sitemap.xml", + "site.webmanifest", + "assets/css/main.css", + "assets/js/main.js", + "assets/js/site-config.json", + "assets/fonts/NotoSerifSC-subset.woff2", + "assets/fonts/OFL.txt", + "assets/images/avatar.jpg", + "assets/images/avatar-frame-animated.png", + "assets/images/avatar-frame-mask.svg", + "assets/images/avatar-frame-static.png", + "assets/images/favicon.svg" +) | Sort-Object + +if (-not $PublicRoot) { + $PublicRoot = $Root +} + +if ($PublicRoot) { + $PublicRoot = (Resolve-Path -LiteralPath $PublicRoot).Path + $actualPublic = @(Get-ChildItem -LiteralPath $PublicRoot -Recurse -File | ForEach-Object { + $_.FullName.Substring($PublicRoot.TrimEnd('\').Length + 1).Replace('\', '/') + } | Sort-Object) + Assert-Check (($actualPublic -join "`n") -eq ($allowedPublic -join "`n")) "public bundle contains only web-root files" +} + +if ($PublicArchive) { + $PublicArchive = (Resolve-Path -LiteralPath $PublicArchive).Path + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($PublicArchive) + try { + $archiveFiles = @($archive.Entries | Where-Object { $_.Name } | ForEach-Object { + $_.FullName.Replace('\', '/').TrimStart('/') + } | Sort-Object) + Assert-Check (($archiveFiles -join "`n") -eq ($allowedPublic -join "`n")) "public ZIP contains only web-root files" + } finally { + $archive.Dispose() + } +} + +Write-Host "Production checks complete." diff --git a/site/404.html b/site/404.html new file mode 100644 index 0000000..3055023 --- /dev/null +++ b/site/404.html @@ -0,0 +1,41 @@ + + + + + + + + 未找到页面 | 中枢 + + + + + + +
+
+
+
+ 中枢头像 + +
+
+
+

Error 404

+

迷路了

+

这里没有光,但可以回到入口。

+
+ +
+
+ + diff --git a/site/assets/css/main.css b/site/assets/css/main.css new file mode 100644 index 0000000..e487fdb --- /dev/null +++ b/site/assets/css/main.css @@ -0,0 +1,483 @@ +@font-face { + font-family: "Noto Serif SC"; + src: url("../fonts/NotoSerifSC-subset.woff2?v=20260818-3") format("woff2"); + font-style: normal; + font-weight: 200 900; + font-display: swap; +} + +:root { + color-scheme: dark; + --ink: #ffffff; + --ink-muted: rgba(255, 255, 255, 0.78); + --line: rgba(255, 255, 255, 0.82); + --line-soft: rgba(255, 255, 255, 0.42); + --rose: #f38caa; + --font-display: "Noto Serif SC", "Source Han Serif SC", "Songti SC", STSong, serif; + font-family: "Microsoft YaHei UI", "PingFang SC", "Source Han Sans SC", "Noto Sans CJK SC", sans-serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + min-height: 100%; + background: #77747f; +} + +body { + min-width: 320px; + min-height: 100vh; + min-height: 100svh; + margin: 0; + overflow-x: hidden; + color: var(--ink); + background: #77747f; +} + +a { + color: inherit; + text-decoration: none; +} + +#sakura { + position: fixed; + z-index: 0; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + background: linear-gradient(180deg, #d8d7dc 0%, #aaa8b2 45%, #66636f 100%); +} + +.landing { + position: relative; + z-index: 2; + display: grid; + min-height: 100vh; + min-height: 100svh; + place-items: start center; + padding: clamp(34px, 6vh, 64px) 24px 28px; +} + +.composition { + position: relative; + display: flex; + width: min(100%, 620px); + flex-direction: column; + align-items: center; + justify-content: flex-start; + text-align: center; + /* Preserve the 100px composition on normal screens and reclaim space on short ones. */ + transform: translateY(clamp(0px, calc(125vh - 800px), 100px)); +} + +.portrait-stage { + position: relative; + z-index: 2; + display: grid; + width: 124px; + height: 124px; + flex: 0 0 auto; + place-items: center; +} + +.portrait-ring { + position: relative; + width: 116px; + height: 116px; + overflow: visible; + border: 3px solid rgba(255, 255, 255, 0.92); + border-radius: 50%; + background: #d9d8dc; + box-shadow: 0 12px 34px rgba(49, 42, 53, 0.24); + transform: rotate(0deg); + transition: transform 720ms ease-in-out; +} + +.portrait-stage:hover .portrait-ring { + transform: rotate(360deg); +} + +.portrait-image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: 50% 43%; + border-radius: 50%; +} + +/* The supplied frame is square. Keep only its central glitch slices so the + circular avatar remains the dominant shape. */ +.portrait-frame { + position: absolute; + z-index: 2; + inset: 0; + overflow: hidden; + pointer-events: none; + border-radius: 50%; + clip-path: circle(50% at 50% 50%); + -webkit-mask-image: url("../images/avatar-frame-mask.svg?v=20260817-2"); + -webkit-mask-mode: alpha; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-image: url("../images/avatar-frame-mask.svg?v=20260817-2"); + mask-mode: alpha; + mask-repeat: no-repeat; + mask-size: 100% 100%; +} + +.portrait-frame-slice { + position: absolute; + display: block; + inset: 0; +} + +.portrait-frame-slice img { + display: block; + width: 100%; + height: 100%; + object-fit: fill; +} + +.intro { + position: relative; + width: 100%; + margin-top: 83px; + margin-bottom: 33px; + padding: 66px 24px 60px; +} + +.eyebrow { + margin: 0 0 14px; + color: rgba(255, 255, 255, 0.68); + font-family: "Segoe UI", Arial, sans-serif; + font-size: 0.66rem; + font-weight: 500; + letter-spacing: 0; + text-transform: uppercase; +} + +h1 { + display: inline-flex; + margin: 0; + align-items: center; + justify-content: center; + gap: 0.3em; + font-family: var(--font-display); + font-size: 3rem; + font-weight: 300; + line-height: 1.16; + letter-spacing: 0; + text-shadow: 0 3px 18px rgba(47, 41, 51, 0.18); +} + +#site-title { + transform-origin: center; + animation: title-glow 2.8s ease-in-out infinite; + will-change: transform, text-shadow; +} + +@keyframes title-glow { + 0%, + 100% { + color: rgba(255, 255, 255, 0.92); + transform: scale(1); + text-shadow: + 0 3px 18px rgba(47, 41, 51, 0.18), + 0 0 2px rgba(255, 255, 255, 0.24), + 0 0 0 rgba(243, 140, 170, 0); + } + + 50% { + color: #fff8fb; + transform: scale(1.06); + text-shadow: + 0 3px 18px rgba(47, 41, 51, 0.18), + 0 0 7px rgba(255, 255, 255, 1), + 0 0 24px rgba(243, 140, 170, 0.95), + 0 0 54px rgba(243, 140, 170, 0.68); + } +} + +.tagline { + margin: 30px 0 0; + color: var(--ink-muted); + font-family: var(--font-display); + font-size: 1.2rem; + font-weight: 300; + letter-spacing: 0; + text-shadow: 0 2px 14px rgba(47, 41, 51, 0.24); +} + +.tagline-chromatic { + position: relative; + color: #ffffff; + font-weight: 500; + mix-blend-mode: lighten; + text-shadow: 0 3px 14px rgba(47, 41, 51, 0.2); +} + +.tagline-chromatic::before, +.tagline-chromatic::after { + position: absolute; + top: 0; + width: 100%; + content: attr(data-text); + background: transparent; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} + +.tagline-chromatic::before { + left: -0.0125em; + text-shadow: 0.05em 0 #ff3f1a; + animation: glitch-loop-1 0.8s infinite ease-in-out alternate-reverse; +} + +.tagline-chromatic::after { + left: 0.0125em; + text-shadow: -0.0625em 0 #00a7e0; + animation: glitch-loop-2 0.8s infinite ease-in-out alternate-reverse; +} + +@keyframes glitch-loop-1 { + 0% { + clip: rect(0.45em, 9999px, 0.1125em, 0); + } + + 25% { + clip: rect(0.3125em, 9999px, 1.2375em, 0); + } + + 50% { + clip: rect(0.625em, 9999px, 1.275em, 0); + } + + 75% { + clip: rect(0.375em, 9999px, 1.15em, 0); + } + + 100% { + clip: rect(1.1375em, 9999px, 1.225em, 0); + } +} + +@keyframes glitch-loop-2 { + 0% { + top: -0.0125em; + left: 0.0125em; + clip: rect(0.8125em, 9999px, 1.4875em, 0); + } + + 25% { + top: -0.075em; + left: 0.05em; + clip: rect(0.9875em, 9999px, 0.2375em, 0); + } + + 50% { + top: -0.0375em; + left: 0.025em; + clip: rect(0.85em, 9999px, 0.1375em, 0); + } + + 75% { + top: 0; + left: -0.05em; + clip: rect(1.1875em, 9999px, 0.6625em, 0); + } + + 100% { + top: -0.0125em; + left: -0.0125em; + clip: rect(0.3875em, 9999px, 1.8625em, 0); + } +} + +.destinations { + position: relative; + z-index: 2; + display: flex; + width: min(100%, 420px); + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 18px; + overflow: visible; +} + +.destinations-single { + width: min(100%, 240px); +} + +.destination { + position: relative; + display: flex; + width: 82px; + height: 82px; + flex: 0 0 82px; + align-items: center; + justify-content: center; + gap: 7px; + min-width: 0; + min-height: 0; + color: rgba(255, 255, 255, 0.92); + font-family: "Segoe UI", Arial, sans-serif; + font-size: 0.78rem; + font-weight: 400; + letter-spacing: 0; + text-transform: uppercase; + border: 1px solid rgba(255, 255, 255, 0.88); + border-radius: 50%; + background: rgba(75, 70, 81, 0.16); + box-shadow: 0 12px 32px rgba(50, 43, 55, 0.13); + backdrop-filter: blur(4px); + transition: background-color 180ms ease, border-color 180ms ease, color 180ms ease, transform 180ms ease; +} + +.destination:hover { + color: #4e4854; + background: rgba(255, 255, 255, 0.82); + border-color: rgba(255, 255, 255, 0.98); + transform: translateY(-3px); +} + +.destination:active { + transform: translateY(-1px); +} + +.destination-label { + line-height: 1; +} + +.external-icon { + font-family: "Segoe UI Symbol", "Arial Unicode MS", sans-serif; + font-size: 0.76rem; + line-height: 1; +} + +.destination:focus-visible { + z-index: 2; + outline: 2px solid #ffffff; + outline-offset: -5px; +} + +.site-footer { + position: fixed; + z-index: 3; + right: 18px; + bottom: 14px; + color: rgba(255, 255, 255, 0.48); + font-family: "Segoe UI", Arial, sans-serif; + font-size: 0.58rem; + letter-spacing: 0; + text-transform: uppercase; +} + +@media (max-width: 720px) { + .landing { + padding: 30px 20px 38px; + } + + .composition { + min-height: 610px; + transform: translateY(clamp(0px, calc(75vh - 480px), 100px)); + } + + .portrait-stage { + width: 106px; + height: 106px; + } + + .portrait-ring { + width: 100px; + height: 100px; + } + + .intro { + margin-top: 65px; + margin-bottom: 29px; + padding: 54px 12px 50px; + } + + h1 { + gap: 0.28em; + font-size: 2.55rem; + } + + .tagline { + margin-top: 24px; + font-size: 1.08rem; + } + + .destinations { + width: min(100%, 330px); + gap: 14px; + } + + .destination { + width: 76px; + height: 76px; + flex-basis: 76px; + font-size: 0.74rem; + } + + .site-footer { + display: none; + } +} + +@media (max-width: 380px) { + .landing { + padding-right: 16px; + padding-left: 16px; + } + + h1 { + gap: 0.24em; + font-size: 2.3rem; + } + +} + +@media (max-height: 720px) and (min-width: 721px) { + .landing { + padding-top: 44px; + padding-bottom: 18px; + } +} + +@media (max-width: 720px) and (max-height: 720px) { + .landing { + padding-bottom: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } + + .tagline-chromatic::before, + .tagline-chromatic::after { + animation: none; + } + + .portrait-ring { + transition: none; + } + + #site-title { + animation: none; + } +} diff --git a/site/assets/fonts/NotoSerifSC-subset.woff2 b/site/assets/fonts/NotoSerifSC-subset.woff2 new file mode 100644 index 0000000..f0d3af1 Binary files /dev/null and b/site/assets/fonts/NotoSerifSC-subset.woff2 differ diff --git a/site/assets/fonts/OFL.txt b/site/assets/fonts/OFL.txt new file mode 100644 index 0000000..94d1bf7 --- /dev/null +++ b/site/assets/fonts/OFL.txt @@ -0,0 +1,94 @@ +Copyright 2012 Google Inc. All Rights Reserved. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/site/assets/images/avatar-frame-animated.png b/site/assets/images/avatar-frame-animated.png new file mode 100644 index 0000000..18c2361 Binary files /dev/null and b/site/assets/images/avatar-frame-animated.png differ diff --git a/site/assets/images/avatar-frame-mask.svg b/site/assets/images/avatar-frame-mask.svg new file mode 100644 index 0000000..eccb2a1 --- /dev/null +++ b/site/assets/images/avatar-frame-mask.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/site/assets/images/avatar-frame-static.png b/site/assets/images/avatar-frame-static.png new file mode 100644 index 0000000..9d6005c Binary files /dev/null and b/site/assets/images/avatar-frame-static.png differ diff --git a/site/assets/images/avatar.jpg b/site/assets/images/avatar.jpg new file mode 100644 index 0000000..70f61b9 Binary files /dev/null and b/site/assets/images/avatar.jpg differ diff --git a/site/assets/images/favicon.svg b/site/assets/images/favicon.svg new file mode 100644 index 0000000..4275608 --- /dev/null +++ b/site/assets/images/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/site/assets/js/main.js b/site/assets/js/main.js new file mode 100644 index 0000000..4c915df --- /dev/null +++ b/site/assets/js/main.js @@ -0,0 +1,1403 @@ +(() => { + + const year = document.querySelector("#year"); + if (year) year.textContent = String(new Date().getFullYear()); + + const navigation = document.querySelector("#site-navigation"); + + function setMetaContent(selector, content) { + const element = document.querySelector(selector); + if (element && content) element.setAttribute("content", content); + } + + function applySiteMetadata(config) { + const site = config && config.site; + if (!site || typeof site.name !== "string" || typeof site.url !== "string") return; + const homepageTitle = document.querySelector('meta[property="og:title"]'); + if (!homepageTitle) return; + + const name = site.name.trim(); + const description = typeof site.description === "string" ? site.description.trim() : ""; + let siteUrl; + try { + siteUrl = new URL(site.url.trim()); + if (siteUrl.protocol !== "https:" && siteUrl.protocol !== "http:") return; + } catch (error) { + return; + } + if (!name || !siteUrl) return; + + siteUrl.pathname = siteUrl.pathname.replace(/\/+$/, "") + "/"; + siteUrl.search = ""; + siteUrl.hash = ""; + const canonicalUrl = siteUrl.href; + const imageUrl = new URL("assets/images/avatar.jpg", canonicalUrl).href; + + document.title = name; + setMetaContent('meta[name="description"]', description); + setMetaContent('meta[property="og:site_name"]', name); + homepageTitle.setAttribute("content", name); + setMetaContent('meta[property="og:description"]', description); + setMetaContent('meta[property="og:url"]', canonicalUrl); + setMetaContent('meta[property="og:image"]', imageUrl); + + const canonical = document.querySelector('link[rel="canonical"]'); + if (canonical) canonical.setAttribute("href", canonicalUrl); + } + + function renderConfiguredLinks(config) { + if (!navigation || !config || !Array.isArray(config.links)) return; + const fragment = document.createDocumentFragment(); + config.links.forEach((item) => { + if (!item || typeof item.name !== "string" || typeof item.url !== "string") return; + const name = item.name.trim(); + const url = item.url.trim(); + if (!name || !/^https?:\/\//i.test(url)) return; + + const link = document.createElement("a"); + link.className = "destination"; + link.href = url; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.title = "在新标签页打开"; + link.setAttribute("aria-label", `${name}(新标签页打开)`); + + const label = document.createElement("span"); + label.className = "destination-label"; + label.textContent = name; + + const icon = document.createElement("span"); + icon.className = "external-icon"; + icon.setAttribute("aria-hidden", "true"); + icon.textContent = "↗"; + + link.append(label, icon); + fragment.append(link); + }); + navigation.replaceChildren(fragment); + } + + if (navigation && window.XMLHttpRequest) { + try { + const request = new XMLHttpRequest(); + request.open("GET", "assets/js/site-config.json", true); + request.responseType = "json"; + request.timeout = 3000; + request.onload = function() { + if (request.status >= 200 && request.status < 300) { + applySiteMetadata(request.response); + renderConfiguredLinks(request.response); + } + }; + request.send(); + } catch (error) { + // Keep the links embedded in the HTML when local-file or network access fails. + } + } + + const shaderSources = { + "sakura_point_vsh": "uniform mat4 uProjection;\nuniform mat4 uModelview;\nuniform vec3 uResolution;\nuniform vec3 uOffset;\nuniform vec3 uDOF; //x:focus distance, y:focus radius, z:max radius\nuniform vec3 uFade; //x:start distance, y:half distance, z:near fade start\n\n\nattribute vec3 aPosition;\nattribute vec3 aEuler;\nattribute vec2 aMisc; //x:size, y:fade\n\n\nvarying vec3 pposition;\nvarying float psize;\nvarying float palpha;\nvarying float pdist;\n\n\n//varying mat3 rotMat;\nvarying vec3 normX;\nvarying vec3 normY;\nvarying vec3 normZ;\nvarying vec3 normal;\n\n\nvarying float diffuse;\nvarying float specular;\nvarying float rstop;\nvarying float distancefade;\n\n\nvoid main(void) {\n // Projection is based on vertical angle\n vec4 pos = uModelview * vec4(aPosition + uOffset, 1.0);\n gl_Position = uProjection * pos;\n gl_PointSize = aMisc.x * uProjection[1][1] / -pos.z * uResolution.y * 0.5;\n \n pposition = pos.xyz;\n psize = aMisc.x;\n pdist = length(pos.xyz);\n palpha = smoothstep(0.0, 1.0, (pdist - 0.1) / uFade.z);\n \n vec3 elrsn = sin(aEuler);\n vec3 elrcs = cos(aEuler);\n mat3 rotx = mat3(\n 1.0, 0.0, 0.0,\n 0.0, elrcs.x, elrsn.x,\n 0.0, -elrsn.x, elrcs.x\n );\n mat3 roty = mat3(\n elrcs.y, 0.0, -elrsn.y,\n 0.0, 1.0, 0.0,\n elrsn.y, 0.0, elrcs.y\n );\n mat3 rotz = mat3(\n elrcs.z, elrsn.z, 0.0, \n -elrsn.z, elrcs.z, 0.0,\n 0.0, 0.0, 1.0\n );\n mat3 rotmat = rotx * roty * rotz;\n normal = rotmat[2];\n \n mat3 trrotm = mat3(\n rotmat[0][0], rotmat[1][0], rotmat[2][0],\n rotmat[0][1], rotmat[1][1], rotmat[2][1],\n rotmat[0][2], rotmat[1][2], rotmat[2][2]\n );\n normX = trrotm[0];\n normY = trrotm[1];\n normZ = trrotm[2];\n \n const vec3 lit = vec3(0.6917144638660746, 0.6917144638660746, -0.20751433915982237);\n \n float tmpdfs = dot(lit, normal);\n if(tmpdfs < 0.0) {\n normal = -normal;\n tmpdfs = dot(lit, normal);\n }\n diffuse = 0.4 + tmpdfs;\n \n vec3 eyev = normalize(-pos.xyz);\n if(dot(eyev, normal) > 0.0) {\n vec3 hv = normalize(eyev + lit);\n specular = pow(max(dot(hv, normal), 0.0), 20.0);\n }\n else {\n specular = 0.0;\n }\n \n rstop = clamp((abs(pdist - uDOF.x) - uDOF.y) / uDOF.z, 0.0, 1.0);\n rstop = pow(rstop, 0.5);\n //-0.69315 = ln(0.5)\n distancefade = min(1.0, exp((uFade.x - pdist) * 0.69315 / uFade.y));\n}", + "sakura_point_fsh": "#ifdef GL_ES\n//precision mediump float;\nprecision highp float;\n#endif\n\n\nuniform vec3 uDOF; //x:focus distance, y:focus radius, z:max radius\nuniform vec3 uFade; //x:start distance, y:half distance, z:near fade start\n\n\nconst vec3 fadeCol = vec3(0.08, 0.03, 0.06);\n\n\nvarying vec3 pposition;\nvarying float psize;\nvarying float palpha;\nvarying float pdist;\n\n\n//varying mat3 rotMat;\nvarying vec3 normX;\nvarying vec3 normY;\nvarying vec3 normZ;\nvarying vec3 normal;\n\n\nvarying float diffuse;\nvarying float specular;\nvarying float rstop;\nvarying float distancefade;\n\n\nfloat ellipse(vec2 p, vec2 o, vec2 r) {\n vec2 lp = (p - o) / r;\n return length(lp) - 1.0;\n}\n\n\nvoid main(void) {\n vec3 p = vec3(gl_PointCoord - vec2(0.5, 0.5), 0.0) * 2.0;\n vec3 d = vec3(0.0, 0.0, -1.0);\n float nd = normZ.z; //dot(-normZ, d);\n if(abs(nd) < 0.0001) discard;\n \n float np = dot(normZ, p);\n vec3 tp = p + d * np / nd;\n vec2 coord = vec2(dot(normX, tp), dot(normY, tp));\n \n //angle = 15 degree\n const float flwrsn = 0.258819045102521;\n const float flwrcs = 0.965925826289068;\n mat2 flwrm = mat2(flwrcs, -flwrsn, flwrsn, flwrcs);\n vec2 flwrp = vec2(abs(coord.x), coord.y) * flwrm;\n \n float r;\n if(flwrp.x < 0.0) {\n r = ellipse(flwrp, vec2(0.065, 0.024) * 0.5, vec2(0.36, 0.96) * 0.5);\n }\n else {\n r = ellipse(flwrp, vec2(0.065, 0.024) * 0.5, vec2(0.58, 0.96) * 0.5);\n }\n \n if(r > rstop) discard;\n \n vec3 col = mix(vec3(1.0, 0.8, 0.75), vec3(1.0, 0.9, 0.87), r);\n float grady = mix(0.0, 1.0, pow(coord.y * 0.5 + 0.5, 0.35));\n col *= vec3(1.0, grady, grady);\n col *= mix(0.8, 1.0, pow(abs(coord.x), 0.3));\n col = col * diffuse + specular;\n \n col = mix(fadeCol, col, distancefade);\n \n float alpha = (rstop > 0.001)? (0.5 - r / (rstop * 2.0)) : 1.0;\n alpha = smoothstep(0.0, 1.0, alpha) * palpha;\n \n gl_FragColor = vec4(col * 0.5, alpha);\n}", + "fx_common_vsh": "uniform vec3 uResolution;\nattribute vec2 aPosition;\n\n\nvarying vec2 texCoord;\nvarying vec2 screenCoord;\n\n\nvoid main(void) {\n gl_Position = vec4(aPosition, 0.0, 1.0);\n texCoord = aPosition.xy * 0.5 + vec2(0.5, 0.5);\n screenCoord = aPosition.xy * vec2(uResolution.z, 1.0);\n}", + "bg_fsh": "#ifdef GL_ES\n//precision mediump float;\nprecision highp float;\n#endif\n\n\nuniform vec2 uTimes;\n\n\nvarying vec2 texCoord;\nvarying vec2 screenCoord;\n\n\nvoid main(void) {\n vec3 col;\n float c;\n vec2 tmpv = texCoord * vec2(0.8, 1.0) - vec2(0.95, 1.0);\n c = exp(-pow(length(tmpv) * 1.8, 2.0));\n col = mix(vec3(0.02, 0.0, 0.03), vec3(0.96, 0.98, 1.0) * 1.5, c);\n gl_FragColor = vec4(col * 0.5, 1.0);\n}", + "fx_brightbuf_fsh": "#ifdef GL_ES\n//precision mediump float;\nprecision highp float;\n#endif\nuniform sampler2D uSrc;\nuniform vec2 uDelta;\n\n\nvarying vec2 texCoord;\nvarying vec2 screenCoord;\n\n\nvoid main(void) {\n vec4 col = texture2D(uSrc, texCoord);\n gl_FragColor = vec4(col.rgb * 2.0 - vec3(0.5), 1.0);\n}", + "fx_dirblur_r4_fsh": "#ifdef GL_ES\n//precision mediump float;\nprecision highp float;\n#endif\nuniform sampler2D uSrc;\nuniform vec2 uDelta;\nuniform vec4 uBlurDir; //dir(x, y), stride(z, w)\n\n\nvarying vec2 texCoord;\nvarying vec2 screenCoord;\n\n\nvoid main(void) {\n vec4 col = texture2D(uSrc, texCoord);\n col = col + texture2D(uSrc, texCoord + uBlurDir.xy * uDelta);\n col = col + texture2D(uSrc, texCoord - uBlurDir.xy * uDelta);\n col = col + texture2D(uSrc, texCoord + (uBlurDir.xy + uBlurDir.zw) * uDelta);\n col = col + texture2D(uSrc, texCoord - (uBlurDir.xy + uBlurDir.zw) * uDelta);\n gl_FragColor = col / 5.0;\n}", + "fx_common_fsh": "#ifdef GL_ES\n//precision mediump float;\nprecision highp float;\n#endif\nuniform sampler2D uSrc;\nuniform vec2 uDelta;\n\n\nvarying vec2 texCoord;\nvarying vec2 screenCoord;\n\n\nvoid main(void) {\n gl_FragColor = texture2D(uSrc, texCoord);\n}", + "pp_final_vsh": "uniform vec3 uResolution;\nattribute vec2 aPosition;\nvarying vec2 texCoord;\nvarying vec2 screenCoord;\nvoid main(void) {\n gl_Position = vec4(aPosition, 0.0, 1.0);\n texCoord = aPosition.xy * 0.5 + vec2(0.5, 0.5);\n screenCoord = aPosition.xy * vec2(uResolution.z, 1.0);\n}", + "pp_final_fsh": "#ifdef GL_ES\n//precision mediump float;\nprecision highp float;\n#endif\nuniform sampler2D uSrc;\nuniform sampler2D uBloom;\nuniform vec2 uDelta;\nvarying vec2 texCoord;\nvarying vec2 screenCoord;\nvoid main(void) {\n vec4 srccol = texture2D(uSrc, texCoord) * 2.0;\n vec4 bloomcol = texture2D(uBloom, texCoord);\n vec4 col;\n col = srccol + bloomcol * (vec4(1.0) + srccol);\n col *= smoothstep(1.0, 0.0, pow(length((texCoord - vec2(0.5)) * 2.0), 1.2) * 0.5);\n col = pow(col, vec4(0.45454545454545)); //(1.0 / 2.2)\n \n gl_FragColor = vec4(col.rgb, 1.0);\n gl_FragColor.a = 1.0;\n}", + }; + + // WebGL ES leaves smoothstep with reversed edges undefined. Keep the + // original vignette curve while expressing it with a defined operation. + shaderSources["pp_final_fsh"] = shaderSources["pp_final_fsh"].replace( + "smoothstep(1.0, 0.0, pow(length((texCoord - vec2(0.5)) * 2.0), 1.2) * 0.5)", + "1.0 - smoothstep(0.0, 1.0, pow(length((texCoord - vec2(0.5)) * 2.0), 1.2) * 0.5)" + ); + + // Utilities + var Vector3 = {}; + var Matrix44 = {}; + Vector3.create = function(x, y, z) { + return {'x':x, 'y':y, 'z':z}; + }; + Vector3.dot = function (v0, v1) { + return v0.x * v1.x + v0.y * v1.y + v0.z * v1.z; + }; + Vector3.cross = function (v, v0, v1) { + v.x = v0.y * v1.z - v0.z * v1.y; + v.y = v0.z * v1.x - v0.x * v1.z; + v.z = v0.x * v1.y - v0.y * v1.x; + }; + Vector3.normalize = function (v) { + var l = v.x * v.x + v.y * v.y + v.z * v.z; + if(l > 0.00001) { + l = 1.0 / Math.sqrt(l); + v.x *= l; + v.y *= l; + v.z *= l; + } + }; + Vector3.arrayForm = function(v) { + if(v.array) { + v.array[0] = v.x; + v.array[1] = v.y; + v.array[2] = v.z; + } + else { + v.array = new Float32Array([v.x, v.y, v.z]); + } + return v.array; + }; + Matrix44.createIdentity = function () { + return new Float32Array([1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]); + }; + Matrix44.loadProjection = function (m, aspect, vdeg, near, far) { + var h = near * Math.tan(vdeg * Math.PI / 180.0 * 0.5) * 2.0; + var w = h * aspect; + + m[0] = 2.0 * near / w; + m[1] = 0.0; + m[2] = 0.0; + m[3] = 0.0; + + m[4] = 0.0; + m[5] = 2.0 * near / h; + m[6] = 0.0; + m[7] = 0.0; + + m[8] = 0.0; + m[9] = 0.0; + m[10] = -(far + near) / (far - near); + m[11] = -1.0; + + m[12] = 0.0; + m[13] = 0.0; + m[14] = -2.0 * far * near / (far - near); + m[15] = 0.0; + }; + Matrix44.loadLookAt = function (m, vpos, vlook, vup) { + var frontv = Vector3.create(vpos.x - vlook.x, vpos.y - vlook.y, vpos.z - vlook.z); + Vector3.normalize(frontv); + var sidev = Vector3.create(1.0, 0.0, 0.0); + Vector3.cross(sidev, vup, frontv); + Vector3.normalize(sidev); + var topv = Vector3.create(1.0, 0.0, 0.0); + Vector3.cross(topv, frontv, sidev); + Vector3.normalize(topv); + + m[0] = sidev.x; + m[1] = topv.x; + m[2] = frontv.x; + m[3] = 0.0; + + m[4] = sidev.y; + m[5] = topv.y; + m[6] = frontv.y; + m[7] = 0.0; + + m[8] = sidev.z; + m[9] = topv.z; + m[10] = frontv.z; + m[11] = 0.0; + + m[12] = -(vpos.x * m[0] + vpos.y * m[4] + vpos.z * m[8]); + m[13] = -(vpos.x * m[1] + vpos.y * m[5] + vpos.z * m[9]); + m[14] = -(vpos.x * m[2] + vpos.y * m[6] + vpos.z * m[10]); + m[15] = 1.0; + }; + + + // + var timeInfo = { + 'start':0, 'prev':0, // performance.now() milliseconds + 'delta':0, 'elapsed':0 // Number(sec) + }; + + function currentTime() { + return (window.performance && window.performance.now) ? window.performance.now() : Date.now(); + } + + + // + var gl; + var renderSpec = { + 'width':0, + 'height':0, + 'aspect':1, + 'array':new Float32Array(3), + 'halfWidth':0, + 'halfHeight':0, + 'halfArray':new Float32Array(3), + 'pixelRatio':1 + // and some render targets. see setViewport() + }; + renderSpec.setSize = function(w, h) { + renderSpec.width = w; + renderSpec.height = h; + renderSpec.aspect = renderSpec.width / Math.max(1, renderSpec.height); + renderSpec.array[0] = renderSpec.width; + renderSpec.array[1] = renderSpec.height; + renderSpec.array[2] = renderSpec.aspect; + + renderSpec.halfWidth = Math.max(1, Math.floor(w / 2)); + renderSpec.halfHeight = Math.max(1, Math.floor(h / 2)); + renderSpec.halfArray[0] = renderSpec.halfWidth; + renderSpec.halfArray[1] = renderSpec.halfHeight; + renderSpec.halfArray[2] = renderSpec.halfWidth / renderSpec.halfHeight; + }; + + + function deleteRenderTarget(rt) { + if(!rt) return; + if(rt.frameBuffer) gl.deleteFramebuffer(rt.frameBuffer); + if(rt.renderBuffer) gl.deleteRenderbuffer(rt.renderBuffer); + if(rt.texture) gl.deleteTexture(rt.texture); + rt.frameBuffer = null; + rt.renderBuffer = null; + rt.texture = null; + } + + + function createRenderTarget(w, h, withDepth) { + w = Math.max(1, Math.floor(w)); + h = Math.max(1, Math.floor(h)); + var ret = { + 'width':w, + 'height':h, + 'hasDepth':!!withDepth, + 'sizeArray':new Float32Array([w, h, w / h]), + 'dtxArray':new Float32Array([1.0 / w, 1.0 / h]) + }; + for(var errorIndex = 0; errorIndex < 16; errorIndex++) { + if(gl.getError() === gl.NO_ERROR) break; + } + ret.frameBuffer = gl.createFramebuffer(); + ret.texture = gl.createTexture(); + + if(!ret.frameBuffer || !ret.texture) { + deleteRenderTarget(ret); + throw new Error("Unable to allocate WebGL render target."); + } + + try { + gl.bindTexture(gl.TEXTURE_2D, ret.texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + 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_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + + gl.bindFramebuffer(gl.FRAMEBUFFER, ret.frameBuffer); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, ret.texture, 0); + + if(ret.hasDepth) { + ret.renderBuffer = gl.createRenderbuffer(); + if(!ret.renderBuffer) throw new Error("Unable to allocate WebGL depth buffer."); + gl.bindRenderbuffer(gl.RENDERBUFFER, ret.renderBuffer); + gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, w, h); + gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, ret.renderBuffer); + } + + var allocationError = gl.getError(); + if(allocationError !== gl.NO_ERROR) { + throw new Error("WebGL render target allocation failed with error 0x" + allocationError.toString(16) + "."); + } + if(gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) { + throw new Error("Incomplete WebGL framebuffer."); + } + + gl.bindTexture(gl.TEXTURE_2D, null); + gl.bindRenderbuffer(gl.RENDERBUFFER, null); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + return ret; + } catch(error) { + unbindRenderTargetState(); + deleteRenderTarget(ret); + throw error; + } + } + + + function deleteRenderTargetSet(targets) { + if(!targets) return; + deleteRenderTarget(targets.mainRT); + deleteRenderTarget(targets.wHalfRT0); + deleteRenderTarget(targets.wHalfRT1); + } + + + function unbindRenderTargetState() { + // The previous frame can leave these textures bound while a resize is + // being committed. Unbind both sampling units before deleting them. + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, null); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, null); + gl.activeTexture(gl.TEXTURE0); + gl.bindRenderbuffer(gl.RENDERBUFFER, null); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + + function estimateRenderTargetSetBytes(width, height) { + var halfWidth = Math.max(1, Math.floor(width / 2)); + var halfHeight = Math.max(1, Math.floor(height / 2)); + // RGBA8 main color + DEPTH_COMPONENT16 + two RGBA8 half targets, with + // headroom for driver alignment and internal depth-buffer padding. + var formatBytes = width * height * 6 + halfWidth * halfHeight * 8; + return Math.ceil(formatBytes * 1.25); + } + + + function createRenderTargetSet(width, height) { + var halfWidth = Math.max(1, Math.floor(width / 2)); + var halfHeight = Math.max(1, Math.floor(height / 2)); + var targets = {}; + try { + targets.mainRT = createRenderTarget(width, height, true); + targets.wHalfRT0 = createRenderTarget(halfWidth, halfHeight, false); + targets.wHalfRT1 = createRenderTarget(halfWidth, halfHeight, false); + return targets; + } catch(error) { + unbindRenderTargetState(); + deleteRenderTargetSet(targets); + throw error; + } + } + + + function compileShader(shtype, shsrc) { + var retsh = gl.createShader(shtype); + if(!retsh) { + console.error("Unable to create WebGL shader."); + return null; + } + if(shtype === gl.FRAGMENT_SHADER && gl.getShaderPrecisionFormat) { + try { + var highp = gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.HIGH_FLOAT); + if(!highp || highp.precision === 0) { + shsrc = shsrc.replace(/precision\s+highp\s+float\s*;/g, "precision mediump float;"); + } + } catch(precisionError) { + shsrc = shsrc.replace(/precision\s+highp\s+float\s*;/g, "precision mediump float;"); + } + } + + gl.shaderSource(retsh, shsrc); + gl.compileShader(retsh); + + if(!gl.getShaderParameter(retsh, gl.COMPILE_STATUS)) { + var errlog = gl.getShaderInfoLog(retsh); + gl.deleteShader(retsh); + console.error(errlog); + return null; + } + return retsh; + } + + + function createShader(vtxsrc, frgsrc, uniformlist, attrlist) { + var vsh = compileShader(gl.VERTEX_SHADER, vtxsrc); + var fsh = compileShader(gl.FRAGMENT_SHADER, frgsrc); + + if(vsh == null || fsh == null) { + if(vsh) gl.deleteShader(vsh); + if(fsh) gl.deleteShader(fsh); + return null; + } + + var prog = gl.createProgram(); + if(!prog) { + gl.deleteShader(vsh); + gl.deleteShader(fsh); + return null; + } + gl.attachShader(prog, vsh); + gl.attachShader(prog, fsh); + + gl.deleteShader(vsh); + gl.deleteShader(fsh); + + gl.linkProgram(prog); + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { + var errlog = gl.getProgramInfoLog(prog); + console.error(errlog); + gl.deleteProgram(prog); + return null; + } + + if(uniformlist) { + prog.uniforms = {}; + for(var i = 0; i < uniformlist.length; i++) { + prog.uniforms[uniformlist[i]] = gl.getUniformLocation(prog, uniformlist[i]); + } + } + + if(attrlist) { + prog.attributes = {}; + for(var i = 0; i < attrlist.length; i++) { + var attr = attrlist[i]; + prog.attributes[attr] = gl.getAttribLocation(prog, attr); + } + } + + return prog; + } + + + function useShader(prog) { + if(!prog) return false; + gl.useProgram(prog); + for(var attr in prog.attributes) { + if(prog.attributes[attr] >= 0) gl.enableVertexAttribArray(prog.attributes[attr]); + } + return true; + } + + + function unuseShader(prog) { + if(!prog) return; + for(var attr in prog.attributes) { + if(prog.attributes[attr] >= 0) gl.disableVertexAttribArray(prog.attributes[attr]); + } + gl.useProgram(null); + } + + + ///// + var projection = { + 'angle':60, + 'nearfar':new Float32Array([0.1, 100.0]), + 'matrix':Matrix44.createIdentity() + }; + var camera = { + 'position':Vector3.create(0, 0, 100), + 'lookat':Vector3.create(0, 0, 0), + 'up':Vector3.create(0, 1, 0), + 'dof':Vector3.create(10.0, 4.0, 8.0), + 'matrix':Matrix44.createIdentity() + }; + + + var pointFlower = {}; + var meshFlower = {}; + var sceneStandBy = false; + + + var BlossomParticle = function () { + this.velocity = new Array(3); + this.rotation = new Array(3); + this.position = new Array(3); + this.euler = new Array(3); + this.size = 1.0; + this.alpha = 1.0; + this.zkey = 0.0; + }; + + + BlossomParticle.prototype.setVelocity = function (vx, vy, vz) { + this.velocity[0] = vx; + this.velocity[1] = vy; + this.velocity[2] = vz; + }; + + + BlossomParticle.prototype.setRotation = function (rx, ry, rz) { + this.rotation[0] = rx; + this.rotation[1] = ry; + this.rotation[2] = rz; + }; + + + BlossomParticle.prototype.setPosition = function (nx, ny, nz) { + this.position[0] = nx; + this.position[1] = ny; + this.position[2] = nz; + }; + + + BlossomParticle.prototype.setEulerAngles = function (rx, ry, rz) { + this.euler[0] = rx; + this.euler[1] = ry; + this.euler[2] = rz; + }; + + + BlossomParticle.prototype.setSize = function (s) { + this.size = s; + }; + + + BlossomParticle.prototype.update = function (dt, et) { + this.position[0] += this.velocity[0] * dt; + this.position[1] += this.velocity[1] * dt; + this.position[2] += this.velocity[2] * dt; + + this.euler[0] += this.rotation[0] * dt; + this.euler[1] += this.rotation[1] * dt; + this.euler[2] += this.rotation[2] * dt; + }; + + + function createPointFlowers() { + // get point sizes + var prm = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE); + renderSpec.pointSize = {'min':prm[0], 'max':prm[1]}; + + var vtxsrc = shaderSources["sakura_point_vsh"]; + var frgsrc = shaderSources["sakura_point_fsh"]; + + pointFlower.program = createShader( + vtxsrc, frgsrc, + ['uProjection', 'uModelview', 'uResolution', 'uOffset', 'uDOF', 'uFade'], + ['aPosition', 'aEuler', 'aMisc'] + ); + if(!pointFlower.program) throw new Error("Unable to create Sakura shader program."); + + useShader(pointFlower.program); + pointFlower.offset = new Float32Array([0.0, 0.0, 0.0]); + pointFlower.fader = Vector3.create(0.0, 10.0, 0.0); + + // paramerters: velocity[3], rotate[3] + pointFlower.numFlowers = 1600; + pointFlower.particles = new Array(pointFlower.numFlowers); + pointFlower.initialized = false; + // vertex attributes {position[3], euler_xyz[3], size[1]} + pointFlower.dataArray = new Float32Array(pointFlower.numFlowers * (3 + 3 + 2)); + pointFlower.positionArrayOffset = 0; + pointFlower.eulerArrayOffset = pointFlower.numFlowers * 3; + pointFlower.miscArrayOffset = pointFlower.numFlowers * 6; + + pointFlower.buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, pointFlower.buffer); + gl.bufferData(gl.ARRAY_BUFFER, pointFlower.dataArray, gl.DYNAMIC_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + + unuseShader(pointFlower.program); + + for(var i = 0; i < pointFlower.numFlowers; i++) { + pointFlower.particles[i] = new BlossomParticle(); + } + } + + + function initPointFlowers() { + //area + pointFlower.area = Vector3.create(20.0, 20.0, 20.0); + pointFlower.area.x = pointFlower.area.y * renderSpec.aspect; + + pointFlower.fader.x = 10.0; //env fade start + pointFlower.fader.y = pointFlower.area.z; //env fade half + pointFlower.fader.z = 0.1; //near fade start + + // A resize changes the camera envelope, not the simulation. Keep the + // existing particles so responsive resizes do not visibly restart it. + if(pointFlower.initialized) return; + + //particles + var PI2 = Math.PI * 2.0; + var tmpv3 = Vector3.create(0, 0, 0); + var tmpv = 0; + var symmetryrand = function() {return (Math.random() * 2.0 - 1.0);}; + for(var i = 0; i < pointFlower.numFlowers; i++) { + var tmpprtcl = pointFlower.particles[i]; + + //velocity + tmpv3.x = symmetryrand() * 0.3 + 0.8; + tmpv3.y = symmetryrand() * 0.2 - 1.0; + tmpv3.z = symmetryrand() * 0.3 + 0.5; + Vector3.normalize(tmpv3); + tmpv = 2.0 + Math.random() * 1.0; + tmpprtcl.setVelocity(tmpv3.x * tmpv, tmpv3.y * tmpv, tmpv3.z * tmpv); + + //rotation + tmpprtcl.setRotation( + symmetryrand() * PI2 * 0.5, + symmetryrand() * PI2 * 0.5, + symmetryrand() * PI2 * 0.5 + ); + + //position + tmpprtcl.setPosition( + symmetryrand() * pointFlower.area.x, + symmetryrand() * pointFlower.area.y, + symmetryrand() * pointFlower.area.z + ); + + //euler + tmpprtcl.setEulerAngles( + Math.random() * Math.PI * 2.0, + Math.random() * Math.PI * 2.0, + Math.random() * Math.PI * 2.0 + ); + + //size + tmpprtcl.setSize(0.9 + Math.random() * 0.1); + } + pointFlower.initialized = true; + } + + + function repeatParticlePosition(prt, cmp, limit) { + if(Math.abs(prt.position[cmp]) - prt.size * 0.5 > limit) { + // Modulo wrapping also handles a resumed tab or a large time step. + var span = limit * 2.0; + var wrapped = (prt.position[cmp] + limit) % span; + if(wrapped < 0.0) wrapped += span; + prt.position[cmp] = wrapped - limit; + } + } + + function repeatParticleEuler(prt, cmp, PI2) { + prt.euler[cmp] = prt.euler[cmp] % PI2; + if(prt.euler[cmp] < 0.0) { + prt.euler[cmp] += PI2; + } + } + + function compareParticleDepth(p0, p1) { + return p0.zkey - p1.zkey; + } + + function renderPointFlowers() { + //update + var PI2 = Math.PI * 2.0; + + for(var i = 0; i < pointFlower.numFlowers; i++) { + var prtcl = pointFlower.particles[i]; + prtcl.update(timeInfo.delta, timeInfo.elapsed); + repeatParticlePosition(prtcl, 0, pointFlower.area.x); + repeatParticlePosition(prtcl, 1, pointFlower.area.y); + repeatParticlePosition(prtcl, 2, pointFlower.area.z); + repeatParticleEuler(prtcl, 0, PI2); + repeatParticleEuler(prtcl, 1, PI2); + repeatParticleEuler(prtcl, 2, PI2); + + prtcl.alpha = 1.0;//(pointFlower.area.z - prtcl.position[2]) * 0.5; + + prtcl.zkey = (camera.matrix[2] * prtcl.position[0] + + camera.matrix[6] * prtcl.position[1] + + camera.matrix[10] * prtcl.position[2] + + camera.matrix[14]); + } + + // sort + pointFlower.particles.sort(compareParticleDepth); + + // update data + var ipos = pointFlower.positionArrayOffset; + var ieuler = pointFlower.eulerArrayOffset; + var imisc = pointFlower.miscArrayOffset; + for(var i = 0; i < pointFlower.numFlowers; i++) { + var prtcl = pointFlower.particles[i]; + pointFlower.dataArray[ipos] = prtcl.position[0]; + pointFlower.dataArray[ipos + 1] = prtcl.position[1]; + pointFlower.dataArray[ipos + 2] = prtcl.position[2]; + ipos += 3; + pointFlower.dataArray[ieuler] = prtcl.euler[0]; + pointFlower.dataArray[ieuler + 1] = prtcl.euler[1]; + pointFlower.dataArray[ieuler + 2] = prtcl.euler[2]; + ieuler += 3; + pointFlower.dataArray[imisc] = prtcl.size; + pointFlower.dataArray[imisc + 1] = prtcl.alpha; + imisc += 2; + } + + //draw + gl.enable(gl.BLEND); + //gl.disable(gl.DEPTH_TEST); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + var prog = pointFlower.program; + useShader(prog); + + gl.uniformMatrix4fv(prog.uniforms.uProjection, false, projection.matrix); + gl.uniformMatrix4fv(prog.uniforms.uModelview, false, camera.matrix); + gl.uniform3fv(prog.uniforms.uResolution, renderSpec.array); + gl.uniform3fv(prog.uniforms.uDOF, Vector3.arrayForm(camera.dof)); + gl.uniform3fv(prog.uniforms.uFade, Vector3.arrayForm(pointFlower.fader)); + + gl.bindBuffer(gl.ARRAY_BUFFER, pointFlower.buffer); + gl.bufferData(gl.ARRAY_BUFFER, pointFlower.dataArray, gl.DYNAMIC_DRAW); + + gl.vertexAttribPointer(prog.attributes.aPosition, 3, gl.FLOAT, false, 0, pointFlower.positionArrayOffset * Float32Array.BYTES_PER_ELEMENT); + gl.vertexAttribPointer(prog.attributes.aEuler, 3, gl.FLOAT, false, 0, pointFlower.eulerArrayOffset * Float32Array.BYTES_PER_ELEMENT); + gl.vertexAttribPointer(prog.attributes.aMisc, 2, gl.FLOAT, false, 0, pointFlower.miscArrayOffset * Float32Array.BYTES_PER_ELEMENT); + + // doubler + for(var i = 1; i < 2; i++) { + var zpos = i * -2.0; + pointFlower.offset[0] = pointFlower.area.x * -1.0; + pointFlower.offset[1] = pointFlower.area.y * -1.0; + pointFlower.offset[2] = pointFlower.area.z * zpos; + gl.uniform3fv(prog.uniforms.uOffset, pointFlower.offset); + gl.drawArrays(gl.POINTS, 0, pointFlower.numFlowers); + + pointFlower.offset[0] = pointFlower.area.x * -1.0; + pointFlower.offset[1] = pointFlower.area.y * 1.0; + pointFlower.offset[2] = pointFlower.area.z * zpos; + gl.uniform3fv(prog.uniforms.uOffset, pointFlower.offset); + gl.drawArrays(gl.POINTS, 0, pointFlower.numFlowers); + + pointFlower.offset[0] = pointFlower.area.x * 1.0; + pointFlower.offset[1] = pointFlower.area.y * -1.0; + pointFlower.offset[2] = pointFlower.area.z * zpos; + gl.uniform3fv(prog.uniforms.uOffset, pointFlower.offset); + gl.drawArrays(gl.POINTS, 0, pointFlower.numFlowers); + + pointFlower.offset[0] = pointFlower.area.x * 1.0; + pointFlower.offset[1] = pointFlower.area.y * 1.0; + pointFlower.offset[2] = pointFlower.area.z * zpos; + gl.uniform3fv(prog.uniforms.uOffset, pointFlower.offset); + gl.drawArrays(gl.POINTS, 0, pointFlower.numFlowers); + } + + //main + pointFlower.offset[0] = 0.0; + pointFlower.offset[1] = 0.0; + pointFlower.offset[2] = 0.0; + gl.uniform3fv(prog.uniforms.uOffset, pointFlower.offset); + gl.drawArrays(gl.POINTS, 0, pointFlower.numFlowers); + + gl.bindBuffer(gl.ARRAY_BUFFER, null); + unuseShader(prog); + + gl.enable(gl.DEPTH_TEST); + gl.disable(gl.BLEND); + } + + + // effects + //common util + function createEffectProgram(vtxsrc, frgsrc, exunifs, exattrs) { + var ret = {}; + var unifs = ['uResolution', 'uSrc', 'uDelta']; + if(exunifs) { + unifs = unifs.concat(exunifs); + } + var attrs = ['aPosition']; + if(exattrs) { + attrs = attrs.concat(exattrs); + } + + ret.program = createShader(vtxsrc, frgsrc, unifs, attrs); + if(!ret.program) throw new Error("Unable to create post-process shader program."); + useShader(ret.program); + + ret.dataArray = new Float32Array([ + -1.0, -1.0, + 1.0, -1.0, + -1.0, 1.0, + 1.0, 1.0 + ]); + ret.buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, ret.buffer); + gl.bufferData(gl.ARRAY_BUFFER, ret.dataArray, gl.STATIC_DRAW); + + gl.bindBuffer(gl.ARRAY_BUFFER, null); + unuseShader(ret.program); + + return ret; + } + + + // basic usage + // useEffect(prog, srctex({'texture':texid, 'dtxArray':(f32)[dtx, dty]})); //basic initialize + // gl.uniform**(...); //additional uniforms + // drawEffect() + // unuseEffect(prog) + // TEXTURE0 makes src + function useEffect(fxobj, srctex) { + var prog = fxobj.program; + useShader(prog); + gl.uniform3fv(prog.uniforms.uResolution, renderSpec.array); + + if(srctex != null) { + gl.uniform2fv(prog.uniforms.uDelta, srctex.dtxArray); + gl.uniform1i(prog.uniforms.uSrc, 0); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, srctex.texture); + } + } + function drawEffect(fxobj) { + gl.bindBuffer(gl.ARRAY_BUFFER, fxobj.buffer); + gl.vertexAttribPointer(fxobj.program.attributes.aPosition, 2, gl.FLOAT, false, 0, 0); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + } + function unuseEffect(fxobj) { + unuseShader(fxobj.program); + } + + + var effectLib = {}; + function createEffectLib() { + + var vtxsrc, frgsrc; + //common + var cmnvtxsrc = shaderSources["fx_common_vsh"]; + + //background + frgsrc = shaderSources["bg_fsh"]; + effectLib.sceneBg = createEffectProgram(cmnvtxsrc, frgsrc, ['uTimes'], null); + + // make brightpixels buffer + frgsrc = shaderSources["fx_brightbuf_fsh"]; + effectLib.mkBrightBuf = createEffectProgram(cmnvtxsrc, frgsrc, null, null); + + // direction blur + frgsrc = shaderSources["fx_dirblur_r4_fsh"]; + effectLib.dirBlur = createEffectProgram(cmnvtxsrc, frgsrc, ['uBlurDir'], null); + + //final composite + vtxsrc = shaderSources["pp_final_vsh"]; + frgsrc = shaderSources["pp_final_fsh"]; + effectLib.finalComp = createEffectProgram(vtxsrc, frgsrc, ['uBloom'], null); + } + + + // background + function createBackground() { + //console.log("create background"); + } + function initBackground() { + //console.log("init background"); + } + function renderBackground() { + gl.disable(gl.DEPTH_TEST); + + useEffect(effectLib.sceneBg, null); + gl.uniform2f(effectLib.sceneBg.program.uniforms.uTimes, timeInfo.elapsed, timeInfo.delta); + drawEffect(effectLib.sceneBg); + unuseEffect(effectLib.sceneBg); + + gl.enable(gl.DEPTH_TEST); + } + + + // post process + var postProcess = {}; + function createPostProcess() { + //console.log("create post process"); + } + function initPostProcess() { + //console.log("init post process"); + } + + + function bindRenderTarget(rt, isclear) { + gl.bindFramebuffer(gl.FRAMEBUFFER, rt.frameBuffer); + gl.viewport(0, 0, rt.width, rt.height); + if(isclear) { + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT | (rt.hasDepth ? gl.DEPTH_BUFFER_BIT : 0)); + } + } + + function renderPostProcess() { + gl.disable(gl.DEPTH_TEST); + + //make bright buff + bindRenderTarget(renderSpec.wHalfRT0, true); + useEffect(effectLib.mkBrightBuf, renderSpec.mainRT); + drawEffect(effectLib.mkBrightBuf); + unuseEffect(effectLib.mkBrightBuf); + + // make bloom + for(var i = 0; i < 2; i++) { + var p = 1.5 + 1 * i; + var s = 2.0 + 1 * i; + bindRenderTarget(renderSpec.wHalfRT1, true); + useEffect(effectLib.dirBlur, renderSpec.wHalfRT0); + gl.uniform4f(effectLib.dirBlur.program.uniforms.uBlurDir, p, 0.0, s, 0.0); + drawEffect(effectLib.dirBlur); + unuseEffect(effectLib.dirBlur); + + bindRenderTarget(renderSpec.wHalfRT0, true); + useEffect(effectLib.dirBlur, renderSpec.wHalfRT1); + gl.uniform4f(effectLib.dirBlur.program.uniforms.uBlurDir, 0.0, p, 0.0, s); + drawEffect(effectLib.dirBlur); + unuseEffect(effectLib.dirBlur); + } + + //display + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, renderSpec.width, renderSpec.height); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + + useEffect(effectLib.finalComp, renderSpec.mainRT); + gl.uniform1i(effectLib.finalComp.program.uniforms.uBloom, 1); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, renderSpec.wHalfRT0.texture); + drawEffect(effectLib.finalComp); + unuseEffect(effectLib.finalComp); + + gl.enable(gl.DEPTH_TEST); + } + + + ///// + var SceneEnv = {}; + function createScene() { + createEffectLib(); + createBackground(); + createPointFlowers(); + createPostProcess(); + sceneStandBy = true; + } + + + function initScene() { + initBackground(); + initPointFlowers(); + initPostProcess(); + + //camera.position.z = 17.320508; + camera.position.z = pointFlower.area.z + projection.nearfar[0]; + projection.angle = Math.atan2(pointFlower.area.y, camera.position.z + pointFlower.area.z) * 180.0 / Math.PI * 2.0; + Matrix44.loadProjection(projection.matrix, renderSpec.aspect, projection.angle, projection.nearfar[0], projection.nearfar[1]); + } + + + function renderScene() { + //draw + Matrix44.loadLookAt(camera.matrix, camera.position, camera.lookat, camera.up); + + gl.enable(gl.DEPTH_TEST); + + //gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.bindFramebuffer(gl.FRAMEBUFFER, renderSpec.mainRT.frameBuffer); + gl.viewport(0, 0, renderSpec.mainRT.width, renderSpec.mainRT.height); + gl.clearColor(0.005, 0, 0.05, 0); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + + renderBackground(); + renderPointFlowers(); + renderPostProcess(); + } + + + ///// + var sakuraCanvas = null; + var resizeTimer = null; + var resizeRequestVersion = 0; + var resizeDebounceMs = 140; + var normalRenderPixelBudget = 10000000; + var constrainedRenderPixelBudget = 6000000; + var transactionalPeakByteLimit = 128 * 1024 * 1024; + var constrainedPeakByteLimit = 96 * 1024 * 1024; + var minimumFallbackPixelRatio = 0.5; + + function hasCompleteRenderTargetSet() { + return !!(renderSpec.mainRT && renderSpec.wHalfRT0 && renderSpec.wHalfRT1); + } + + function getRenderPixelBudget() { + var deviceMemory = Number(navigator.deviceMemory); + if(isFinite(deviceMemory) && deviceMemory > 0 && deviceMemory <= 4) { + return constrainedRenderPixelBudget; + } + return normalRenderPixelBudget; + } + + function getRenderDimensionLimit() { + if(!gl || !gl.getParameter) return Infinity; + var maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE) || Infinity; + var maxRenderbufferSize = gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || Infinity; + return Math.min(maxTextureSize, maxRenderbufferSize); + } + + function createCanvasSize(cssWidth, cssHeight, pixelRatio, requestedPixelRatio) { + var width = Math.max(1, Math.round(cssWidth * pixelRatio)); + var height = Math.max(1, Math.round(cssHeight * pixelRatio)); + return { + 'cssWidth':cssWidth, + 'cssHeight':cssHeight, + 'width':width, + 'height':height, + 'pixels':width * height, + 'pixelRatio':pixelRatio, + 'requestedPixelRatio':requestedPixelRatio + }; + } + + function measureCanvasSize() { + var d = document.documentElement; + var cssWidth = Math.max(1, window.innerWidth || d.clientWidth || 1); + var cssHeight = Math.max(1, window.innerHeight || d.clientHeight || 1); + var requestedPixelRatio = Math.min(Math.max(window.devicePixelRatio || 1, 1), 2); + var budgetRatio = Math.sqrt(getRenderPixelBudget() / (cssWidth * cssHeight)); + var dimensionLimit = getRenderDimensionLimit(); + var dimensionRatio = Math.min(dimensionLimit / cssWidth, dimensionLimit / cssHeight); + var pixelRatio = Math.min(requestedPixelRatio, budgetRatio, dimensionRatio); + + if(!isFinite(pixelRatio) || pixelRatio <= 0) pixelRatio = 0.25; + return createCanvasSize(cssWidth, cssHeight, pixelRatio, requestedPixelRatio); + } + + function getResizeCandidates(canvasSize) { + var ratios = []; + var floorRatio = canvasSize.pixelRatio < minimumFallbackPixelRatio ? + canvasSize.pixelRatio * 0.5 : minimumFallbackPixelRatio; + var factors = [1.0, 0.8, 0.6, 0.5]; + + for(var i = 0; i < factors.length; i++) { + var ratio = Math.max(floorRatio, canvasSize.pixelRatio * factors[i]); + var duplicate = false; + for(var j = 0; j < ratios.length; j++) { + if(Math.abs(ratios[j] - ratio) < 0.0001) { + duplicate = true; + break; + } + } + if(!duplicate) ratios.push(ratio); + } + + return ratios.map(function(ratio) { + return createCanvasSize(canvasSize.cssWidth, canvasSize.cssHeight, ratio, canvasSize.requestedPixelRatio); + }); + } + + function shouldReleaseBeforeAllocate(previousTargets, width, height) { + if(!previousTargets || !previousTargets.mainRT) return false; + var deviceMemory = Number(navigator.deviceMemory); + var peakLimit = isFinite(deviceMemory) && deviceMemory > 0 && deviceMemory <= 4 ? + constrainedPeakByteLimit : transactionalPeakByteLimit; + var oldBytes = estimateRenderTargetSetBytes(previousTargets.mainRT.width, previousTargets.mainRT.height); + var nextBytes = estimateRenderTargetSetBytes(width, height); + return oldBytes + nextBytes > peakLimit; + } + + function commitCanvasSize(canvas, canvasSize) { + if(canvas.width !== canvasSize.width) canvas.width = canvasSize.width; + if(canvas.height !== canvasSize.height) canvas.height = canvasSize.height; + } + + function onResize() { + if(!sakuraCanvas || contextLost) return; + if(resizeTimer !== null) window.clearTimeout(resizeTimer); + var requestVersion = ++resizeRequestVersion; + resizeTimer = window.setTimeout(function() { + resizeTimer = null; + window.requestAnimationFrame(function() { + if(contextLost || requestVersion !== resizeRequestVersion) return; + var canvasSize = measureCanvasSize(); + if(hasCompleteRenderTargetSet() && renderSpec.width === canvasSize.width && renderSpec.height === canvasSize.height) { + renderSpec.pixelRatio = canvasSize.pixelRatio; + return; + } + try { + setViewports(canvasSize); + if(sceneStandBy) initScene(); + if(prefersReducedMotion && sceneStandBy) render(); + else if(animating) scheduleAnimationFrame(); + } catch(error) { + // The canvas backing size was not changed, so its last + // composited frame remains visible while a later retry is + // requested by another resize or context restoration. + console.error("Unable to resize Sakura render targets.", error); + if(prefersReducedMotion && sceneStandBy && hasCompleteRenderTargetSet()) render(); + } + }); + }, resizeDebounceMs); + } + + + function setViewports(canvasSize) { + var width = Math.max(1, canvasSize.width); + var height = Math.max(1, canvasSize.height); + if(hasCompleteRenderTargetSet() && renderSpec.width === width && renderSpec.height === height) { + renderSpec.pixelRatio = canvasSize.pixelRatio; + gl.viewport(0, 0, width, height); + return false; + } + + var previousTargets = { + 'mainRT':renderSpec.mainRT, + 'wHalfRT0':renderSpec.wHalfRT0, + 'wHalfRT1':renderSpec.wHalfRT1 + }; + var releaseBeforeAllocate = shouldReleaseBeforeAllocate(previousTargets, width, height); + + if(releaseBeforeAllocate) { + // This path deliberately trades allocation rollback for a bounded + // peak. The default framebuffer is kept at its old size until the + // replacement set is ready, so the last composited frame remains on + // screen during the short resize transaction. + gl.finish(); + unbindRenderTargetState(); + deleteRenderTargetSet(previousTargets); + renderSpec.mainRT = null; + renderSpec.wHalfRT0 = null; + renderSpec.wHalfRT1 = null; + } + + var nextTargets = null; + var lastError = null; + var candidates = getResizeCandidates(canvasSize); + for(var i = 0; i < candidates.length; i++) { + try { + nextTargets = createRenderTargetSet(candidates[i].width, candidates[i].height); + canvasSize = candidates[i]; + break; + } catch(error) { + lastError = error; + if(gl.isContextLost && gl.isContextLost()) throw error; + } + } + if(!nextTargets) { + if(!releaseBeforeAllocate) { + unbindRenderTargetState(); + } + throw lastError || new Error("Unable to allocate Sakura render targets."); + } + + commitCanvasSize(sakuraCanvas, canvasSize); + renderSpec.mainRT = nextTargets.mainRT; + renderSpec.wHalfRT0 = nextTargets.wHalfRT0; + renderSpec.wHalfRT1 = nextTargets.wHalfRT1; + renderSpec.setSize(canvasSize.width, canvasSize.height); + renderSpec.pixelRatio = canvasSize.pixelRatio; + gl.clearColor(0.2, 0.2, 0.5, 1.0); + gl.viewport(0, 0, renderSpec.width, renderSpec.height); + if(!releaseBeforeAllocate) { + unbindRenderTargetState(); + deleteRenderTargetSet(previousTargets); + } + return true; + } + + + function render() { + if(!hasCompleteRenderTargetSet() || contextLost) return false; + renderScene(); + return true; + } + + + var animating = true; + var contextLost = false; + var sakuraInitialized = false; + var pageVisible = !document.hidden; + var framePending = false; + var frameRequestId = null; + var frameRequestGeneration = 0; + var maximumFrameRate = 120; + var minimumFrameInterval = 1000.0 / maximumFrameRate; + var frameIntervalTolerance = 0.01; + var frameAccumulator = 0; + var previousAnimationFrameTime = 0; + var reducedMotionQuery = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null; + var prefersReducedMotion = !!(reducedMotionQuery && reducedMotionQuery.matches); + + function resetFrameLimiter(now) { + previousAnimationFrameTime = now; + frameAccumulator = 0; + timeInfo.prev = now; + timeInfo.delta = 0; + } + + function shouldRenderFrame(now) { + var animationFrameDelta = Math.min(Math.max(now - previousAnimationFrameTime, 0), 250); + previousAnimationFrameTime = now; + frameAccumulator += animationFrameDelta; + if(frameAccumulator + frameIntervalTolerance < minimumFrameInterval) return false; + + // Preserve fractional time so 144/165 Hz displays converge to 120 FPS + // instead of falling to a coarse refresh-rate divisor. + frameAccumulator -= minimumFrameInterval; + if(frameAccumulator < 0) frameAccumulator = 0; + if(frameAccumulator >= minimumFrameInterval) frameAccumulator %= minimumFrameInterval; + if(minimumFrameInterval - frameAccumulator < frameIntervalTolerance) frameAccumulator = 0; + return true; + } + + function cancelScheduledAnimationFrame() { + frameRequestGeneration++; + if(frameRequestId !== null && window.cancelAnimationFrame) { + window.cancelAnimationFrame(frameRequestId); + } + frameRequestId = null; + framePending = false; + } + + function scheduleAnimationFrame() { + if(framePending || !animating || !pageVisible || contextLost || prefersReducedMotion || !hasCompleteRenderTargetSet()) return; + framePending = true; + var requestGeneration = frameRequestGeneration; + frameRequestId = window.requestAnimationFrame(function(timestamp) { + if(requestGeneration !== frameRequestGeneration) return; + framePending = false; + frameRequestId = null; + animate(timestamp); + }); + } + + function readPageVisibility() { + if(typeof document.hidden === "boolean") return !document.hidden; + return document.visibilityState !== "hidden"; + } + + function synchronizeAnimationState(reducedMotionOverride) { + pageVisible = readPageVisibility(); + if(typeof reducedMotionOverride === "boolean") { + prefersReducedMotion = reducedMotionOverride; + } else if(reducedMotionQuery) { + prefersReducedMotion = !!reducedMotionQuery.matches; + } + + if(!pageVisible || contextLost || !animating) { + cancelScheduledAnimationFrame(); + return; + } + if(!sceneStandBy || !hasCompleteRenderTargetSet()) return; + + // A browser can discard a queued frame while a page is hidden or being + // restored. Replace any pending request so cold starts cannot retain a + // stale framePending flag. Generation guards isolate the old callback. + if(framePending) cancelScheduledAnimationFrame(); + resetFrameLimiter(currentTime()); + if(prefersReducedMotion) { + timeInfo.delta = 0; + render(); + } else { + scheduleAnimationFrame(); + } + } + + function onReducedMotionChange(event) { + var nextPreference = !!event.matches; + if(nextPreference === prefersReducedMotion) return; + synchronizeAnimationState(nextPreference); + } + + function onPageHide() { + pageVisible = false; + cancelScheduledAnimationFrame(); + } + + function toggleAnimation(elm) { + animating = !animating; + if(animating) { + synchronizeAnimationState(); + } else { + cancelScheduledAnimationFrame(); + } + if(elm) { + elm.innerHTML = animating? "Stop":"Start"; + } + } + + + function stepAnimation() { + if(!animating && pageVisible && !contextLost) animate(currentTime(), true); + } + + + function animate(timestamp, singleStep) { + if(contextLost || !pageVisible || (prefersReducedMotion && !singleStep) || (!animating && !singleStep)) return; + var now = typeof timestamp === "number" ? timestamp : currentTime(); + if(!timeInfo.start) timeInfo.start = now; + if(!singleStep && !shouldRenderFrame(now)) { + scheduleAnimationFrame(); + return; + } + timeInfo.elapsed = (now - timeInfo.start) / 1000.0; + timeInfo.delta = Math.min(Math.max((now - timeInfo.prev) / 1000.0, 0.0), 0.05); + timeInfo.prev = now; + render(); + if(animating && !prefersReducedMotion) scheduleAnimationFrame(); + } + + + function initializeSakura() { + if(sakuraInitialized) return; + sakuraCanvas = document.getElementById("sakura"); + if(!sakuraCanvas) return; + sakuraInitialized = true; + var initialCanvasSize = null; + try { + gl = sakuraCanvas.getContext('webgl', { + alpha: true, + antialias: false, + depth: true, + powerPreference: 'high-performance' + }) || sakuraCanvas.getContext('experimental-webgl'); + if(!gl) throw new Error("WebGL is unavailable."); + initialCanvasSize = measureCanvasSize(); + } catch(e) { + console.error(e); + sakuraCanvas.hidden = true; + return; + } + + window.addEventListener('resize', onResize); + sakuraCanvas.addEventListener('webglcontextlost', function(event) { + event.preventDefault(); + contextLost = true; + cancelScheduledAnimationFrame(); + resizeRequestVersion++; + if(resizeTimer !== null) { + window.clearTimeout(resizeTimer); + resizeTimer = null; + } + }, false); + sakuraCanvas.addEventListener('webglcontextrestored', function() { + contextLost = false; + sceneStandBy = false; + pointFlower = {}; + effectLib = {}; + renderSpec.mainRT = null; + renderSpec.wHalfRT0 = null; + renderSpec.wHalfRT1 = null; + try { + var restoredCanvasSize = measureCanvasSize(); + setViewports(restoredCanvasSize); + createScene(); + initScene(); + timeInfo.start = currentTime(); + resetFrameLimiter(timeInfo.start); + synchronizeAnimationState(); + } catch(error) { + contextLost = true; + console.error(error); + sakuraCanvas.hidden = true; + } + }, false); + + try { + setViewports(initialCanvasSize); + createScene(); + initScene(); + timeInfo.start = currentTime(); + resetFrameLimiter(timeInfo.start); + synchronizeAnimationState(); + } catch(error) { + console.error(error); + sakuraCanvas.hidden = true; + } + } + + + // Set animation-frame fallbacks for older embedded browsers. + (function(w) { + w.requestAnimationFrame = w.requestAnimationFrame || w.webkitRequestAnimationFrame || w.mozRequestAnimationFrame || + function(callback) { return w.setTimeout(callback, 1000 / 60); }; + w.cancelAnimationFrame = w.cancelAnimationFrame || w.webkitCancelAnimationFrame || w.mozCancelAnimationFrame || + function(requestId) { w.clearTimeout(requestId); }; + })(window); + + if(reducedMotionQuery) { + if(reducedMotionQuery.addEventListener) reducedMotionQuery.addEventListener('change', onReducedMotionChange); + else if(reducedMotionQuery.addListener) reducedMotionQuery.addListener(onReducedMotionChange); + } + document.addEventListener('visibilitychange', synchronizeAnimationState); + window.addEventListener('pagehide', onPageHide); + window.addEventListener('pageshow', synchronizeAnimationState); + + if(document.readyState === "loading") { + document.addEventListener('DOMContentLoaded', initializeSakura, { once:true }); + } else { + initializeSakura(); + } +})(); diff --git a/site/assets/js/site-config.json b/site/assets/js/site-config.json new file mode 100644 index 0000000..d8c3449 --- /dev/null +++ b/site/assets/js/site-config.json @@ -0,0 +1,11 @@ +{ + "site": { + "name": "中枢", + "description": "你也可以变成光", + "url": "https://midctrl.com" + }, + "links": [ + { "name": "Blog", "url": "https://wp.midctrl.com" }, + { "name": "Tools", "url": "https://tools.midctrl.com" } + ] +} diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..e3d59b6 --- /dev/null +++ b/site/index.html @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + 中枢 + + + + + + + + + + +
+
+
+
+ 中枢头像 + +
+
+ +
+

中枢

+

你也可以变成光

+
+ + + + +
+
+ + + + + diff --git a/site/robots.txt b/site/robots.txt new file mode 100644 index 0000000..e4e91d6 --- /dev/null +++ b/site/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://midctrl.com/sitemap.xml diff --git a/site/site.webmanifest b/site/site.webmanifest new file mode 100644 index 0000000..c9e136a --- /dev/null +++ b/site/site.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "中枢", + "short_name": "中枢", + "description": "你也可以变成光", + "lang": "zh-CN", + "start_url": "/", + "display": "standalone", + "background_color": "#77747f", + "theme_color": "#77747f", + "icons": [ + { + "src": "/assets/images/avatar.jpg", + "sizes": "512x512", + "type": "image/jpeg" + } + ] +} diff --git a/site/sitemap.xml b/site/sitemap.xml new file mode 100644 index 0000000..334ea9d --- /dev/null +++ b/site/sitemap.xml @@ -0,0 +1,6 @@ + + + + https://midctrl.com/ + + diff --git a/tests/test-render-target-resize.js b/tests/test-render-target-resize.js new file mode 100644 index 0000000..7a33a0e --- /dev/null +++ b/tests/test-render-target-resize.js @@ -0,0 +1,337 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const mainPath = process.argv[2] + ? path.resolve(process.argv[2]) + : path.join(__dirname, "midctrl-homepage-optimized", "assets", "js", "main.js"); +const source = fs.readFileSync(mainPath, "utf8"); + +const events = []; +const consoleErrors = []; +const windowListeners = new Map(); +const documentListeners = new Map(); +const canvasListeners = new Map(); +const timers = new Map(); +let nextTimerId = 1; +let rafQueue = []; +let rafTime = 0; +let nextObjectId = 1; +let reducedMotionChangeListener = null; +let documentHidden = true; +let documentReadyState = "loading"; + +function addListener(store, type, callback) { + if (!store.has(type)) store.set(type, []); + store.get(type).push(callback); +} + +function emit(store, type, event = {}) { + for (const callback of store.get(type) || []) callback(event); +} + +function flushTimers() { + const pending = [...timers.entries()]; + timers.clear(); + for (const [, callback] of pending) callback(); +} + +function flushAnimationFrames() { + const pending = rafQueue; + rafQueue = []; + rafTime += 16.667; + for (const callback of pending) callback(rafTime); +} + +function makeObject(type) { + return { type, id: nextObjectId++ }; +} + +const constants = { + NO_ERROR: 0, + FRAMEBUFFER_COMPLETE: 0x8cd5, + MAX_TEXTURE_SIZE: 0x0d33, + MAX_RENDERBUFFER_SIZE: 0x84e8, + ALIASED_POINT_SIZE_RANGE: 0x846d, + COMPILE_STATUS: 0x8b81, + LINK_STATUS: 0x8b82, + VERTEX_SHADER: 0x8b31, + FRAGMENT_SHADER: 0x8b30, + HIGH_FLOAT: 0x8df2, + TEXTURE0: 0x84c0, + TEXTURE1: 0x84c1, +}; + +const glTarget = { + ...constants, + failTexturePixelsAbove: Infinity, + createFramebuffer() { + events.push("createFramebuffer"); + return makeObject("framebuffer"); + }, + deleteFramebuffer() { + events.push("deleteFramebuffer"); + }, + createTexture() { + return makeObject("texture"); + }, + deleteTexture() {}, + createRenderbuffer() { + return makeObject("renderbuffer"); + }, + deleteRenderbuffer() {}, + createShader() { + return makeObject("shader"); + }, + createProgram() { + return makeObject("program"); + }, + createBuffer() { + return makeObject("buffer"); + }, + texImage2D(target, level, internalFormat, width, height) { + if (width * height > this.failTexturePixelsAbove) { + events.push("textureAllocationFailed"); + throw new Error("Synthetic texture allocation failure."); + } + }, + getParameter(parameter) { + if (parameter === this.MAX_TEXTURE_SIZE || parameter === this.MAX_RENDERBUFFER_SIZE) return 16384; + if (parameter === this.ALIASED_POINT_SIZE_RANGE) return [1, 64]; + return null; + }, + getShaderPrecisionFormat() { + return { precision: 23 }; + }, + getShaderParameter() { + return true; + }, + getProgramParameter() { + return true; + }, + getShaderInfoLog() { + return ""; + }, + getProgramInfoLog() { + return ""; + }, + getUniformLocation() { + return makeObject("uniform"); + }, + getAttribLocation() { + return 0; + }, + checkFramebufferStatus() { + return this.FRAMEBUFFER_COMPLETE; + }, + getError() { + return this.NO_ERROR; + }, + isContextLost() { + return false; + }, + finish() { + events.push("finish"); + }, +}; + +let generatedConstant = 0x10000; +const gl = new Proxy(glTarget, { + get(target, property) { + if (property in target) return target[property]; + if (typeof property === "string" && /^[A-Z0-9_]+$/.test(property)) { + target[property] = generatedConstant++; + return target[property]; + } + if (typeof property === "string") { + target[property] = function noop() {}; + return target[property]; + } + return undefined; + }, +}); + +const canvas = { + width: 300, + height: 150, + hidden: false, + getContext() { + return gl; + }, + addEventListener(type, callback) { + addListener(canvasListeners, type, callback); + }, +}; +gl.canvas = canvas; + +const year = { textContent: "" }; +const mediaQuery = { + matches: false, + addEventListener(type, callback) { + if (type === "change") reducedMotionChangeListener = callback; + }, + addListener(callback) { + reducedMotionChangeListener = callback; + }, +}; +const context = { + console: { + log() {}, + warn() {}, + error(...args) { + consoleErrors.push(args.map(String).join(" ")); + }, + }, + document: { + get hidden() { + return documentHidden; + }, + get visibilityState() { + return documentHidden ? "hidden" : "visible"; + }, + get readyState() { + return documentReadyState; + }, + documentElement: { clientWidth: 1280, clientHeight: 720 }, + querySelector(selector) { + if (selector === "#year") return year; + return null; + }, + getElementById(id) { + return id === "sakura" ? canvas : null; + }, + addEventListener(type, callback) { + addListener(documentListeners, type, callback); + }, + }, + navigator: { deviceMemory: 8 }, + innerWidth: 1280, + innerHeight: 720, + devicePixelRatio: 1, + performance: { now: () => rafTime }, + matchMedia() { + return mediaQuery; + }, + addEventListener(type, callback) { + addListener(windowListeners, type, callback); + }, + setTimeout(callback) { + const id = nextTimerId++; + timers.set(id, callback); + return id; + }, + clearTimeout(id) { + timers.delete(id); + }, + requestAnimationFrame(callback) { + rafQueue.push(callback); + return rafQueue.length; + }, + cancelAnimationFrame() { + // Deliberately leave the callback queued to verify generation guards in + // browsers where cancellation races with a frame already being dispatched. + events.push("cancelAnimationFrame"); + }, + Date, + Math, + Number, + Object, + Array, + Float32Array, + Uint8Array, + isFinite, +}; +context.window = context; +context.globalThis = context; + +vm.runInNewContext(source, context, { filename: "main.js" }); + +assert.equal(events.filter((event) => event === "createFramebuffer").length, 0, "WebGL must wait only for the DOM"); +documentHidden = false; +documentReadyState = "interactive"; +emit(documentListeners, "DOMContentLoaded"); + +assert.equal(consoleErrors.length, 0, "initialization must not log errors"); +assert.deepEqual([canvas.width, canvas.height], [1280, 720]); +assert.equal(events.filter((event) => event === "createFramebuffer").length, 3); +assert.equal(rafQueue.length, 1, "a missed pre-DOM visibility transition must still start animation"); + +function resize(width, height, dpr, burstCount = 1) { + context.innerWidth = width; + context.innerHeight = height; + context.devicePixelRatio = dpr; + context.document.documentElement.clientWidth = width; + context.document.documentElement.clientHeight = height; + for (let i = 0; i < burstCount; i++) emit(windowListeners, "resize"); + assert.equal(timers.size, 1, "resize bursts must collapse to one timer"); + flushTimers(); + flushAnimationFrames(); +} + +events.length = 0; +resize(1366, 768, 1, 25); +assert.equal(events.filter((event) => event === "createFramebuffer").length, 3); +assert.equal(events.filter((event) => event === "deleteFramebuffer").length, 3); +assert.ok(events.indexOf("createFramebuffer") < events.indexOf("deleteFramebuffer")); + +events.length = 0; +resize(3000, 1800, 2); +assert.ok(canvas.width * canvas.height <= 10000000, "normal pixel budget must be enforced"); + +events.length = 0; +resize(3200, 1800, 2); +assert.equal(events.filter((event) => event === "finish").length, 1); +assert.equal(events.filter((event) => event === "deleteFramebuffer").length, 3); +assert.equal(events.filter((event) => event === "createFramebuffer").length, 3); +assert.ok(events.indexOf("deleteFramebuffer") < events.indexOf("createFramebuffer")); + +events.length = 0; +gl.failTexturePixelsAbove = 5000000; +resize(3600, 2000, 2); +assert.ok(events.filter((event) => event === "textureAllocationFailed").length >= 2); +assert.ok(canvas.width * canvas.height <= 5000000, "failed allocations must fall back to a smaller target"); +assert.equal(consoleErrors.length, 0, "successful fallback must not log a terminal error"); + +events.length = 0; +gl.failTexturePixelsAbove = Infinity; +let contextLossPrevented = false; + +documentHidden = true; +emit(windowListeners, "pagehide", { persisted: true }); +assert.equal(events.filter((event) => event === "cancelAnimationFrame").length, 1); +assert.equal(rafQueue.length, 1, "the mock retains the stale frame after pagehide"); +documentHidden = false; +emit(windowListeners, "pageshow", { persisted: true }); +assert.equal(rafQueue.length, 2, "pageshow must queue a replacement frame"); +emit(windowListeners, "pageshow", { persisted: true }); +assert.equal(rafQueue.length, 3, "a repeated pageshow replaces the pending frame in the mock queue"); +flushAnimationFrames(); +assert.equal(rafQueue.length, 1, "only the newest pageshow loop may continue"); + +events.length = 0; +mediaQuery.matches = true; +reducedMotionChangeListener({ matches: true }); +assert.equal(events.filter((event) => event === "cancelAnimationFrame").length, 1); +flushAnimationFrames(); +assert.equal(rafQueue.length, 0, "reduced motion must leave no live animation callback"); +mediaQuery.matches = false; +reducedMotionChangeListener({ matches: false }); +assert.equal(rafQueue.length, 1, "animation must have one pending frame before context loss"); + +events.length = 0; +emit(canvasListeners, "webglcontextlost", { + preventDefault() { + contextLossPrevented = true; + }, +}); +emit(canvasListeners, "webglcontextrestored"); +assert.equal(contextLossPrevented, true); +assert.equal(events.filter((event) => event === "createFramebuffer").length, 3); +assert.equal(events.filter((event) => event === "cancelAnimationFrame").length, 1); +assert.equal(rafQueue.length, 2, "the mock keeps both the stale and restored callbacks queued"); +flushAnimationFrames(); +assert.equal(rafQueue.length, 1, "only the restored animation loop may schedule the next frame"); +assert.equal(consoleErrors.length, 0, "context restoration must not log errors"); + +console.log("PASS: cold start, page recovery, Render Target resize, fallback, and context restoration");