<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>SunskyXH</title>
        <link>https://paragraph.com/@sunskyxh</link>
        <description>undefined</description>
        <lastBuildDate>Thu, 14 May 2026 16:26:48 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <image>
            <title>SunskyXH</title>
            <url>https://storage.googleapis.com/papyrus_images/377c102c60649e93d2bc6dbc4c97ee908698ece07b968a5389a6b40015b681a6.png</url>
            <link>https://paragraph.com/@sunskyxh</link>
        </image>
        <copyright>All rights reserved</copyright>
        <item>
            <title><![CDATA[用Zdog做一个(伪)3D的小宇宙App图标]]></title>
            <link>https://paragraph.com/@sunskyxh/zdog-3d-app</link>
            <guid>paCDtsXmJv8oGw5w9wIj</guid>
            <pubDate>Thu, 28 Oct 2021 11:29:49 GMT</pubDate>
            <description><![CDATA[之前简单玩了玩Zdog，用它制作了一个非常简单的3D版小宇宙图标，把过程中遇到的一些趣事分享给大家。Zdog简介Zdog是一个绘制伪3D模型的库，可以输出成Canvas或者svg。模型的geometry是在3D空间中进行计算的，渲染出来的是想被拍平的2D图像。 基于这种特性，Zdog有着一些自己的特点，例如可以更好的画出「圆」的东西。3D中球体的geometry可能是由很多个三角形拼起来的，而Zdog实际渲染出的球体其实是一个平面的圆形，相比真正的球体简单了很多。 对比threejs之类专门绘制3D图像的库，Zdog没有诸如mesh, texture, material, geometry之类的概念，只需要实例化自带的图形然后将他们添加到画布上就能看到图像出现在显示器上了。但也是由于一切都被简化了，像z-fighting之类的问题非常容易出现，下文的例子中就遇到了。Quick Start使用Zdog非常简单，只需要创建一个<canvas>，实例化一个Illustration 和任意的图形，执行一下render方法就可以获得画面了。// create illo let illo =...]]></description>
            <content:encoded><![CDATA[<p>之前简单玩了玩Zdog，用它制作了一个非常简单的3D版小宇宙图标，把过程中遇到的一些趣事分享给大家。</p><h2 id="h-zdog" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">Zdog简介</h2><p>Zdog是一个绘制伪3D模型的库，可以输出成Canvas或者svg。模型的geometry是在3D空间中进行计算的，渲染出来的是想被拍平的2D图像。</p><p>基于这种特性，Zdog有着一些自己的特点，例如可以更好的画出「圆」的东西。3D中球体的geometry可能是由很多个三角形拼起来的，而Zdog实际渲染出的球体其实是一个平面的圆形，相比真正的球体简单了很多。</p><p>对比threejs之类专门绘制3D图像的库，Zdog没有诸如mesh, texture, material, geometry之类的概念，只需要实例化自带的图形然后将他们添加到画布上就能看到图像出现在显示器上了。但也是由于一切都被简化了，像<a target="_blank" rel="noopener noreferrer nofollow ugc" class="dont-break-out" href="https://zzz.dog/extras#z-fighting">z-fighting</a>之类的问题非常容易出现，下文的例子中就遇到了。</p><h2 id="h-quick-start" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">Quick Start</h2><p>使用Zdog非常简单，只需要创建一个<code>&lt;canvas&gt;</code>，实例化一个<code>Illustration</code> 和任意的图形，执行一下render方法就可以获得画面了。</p><pre data-type="codeBlock" text="// create illo
let illo = new Zdog.Illustration({
  // set canvas with selector
  element: &apos;.zdog-canvas&apos;,
});

// add circle
new Zdog.Ellipse({
  addTo: illo,
  diameter: 80,
  stroke: 20,
  color: &apos;#636&apos;,
});

// update &amp; render
illo.updateRenderGraph();
"><code><span class="hljs-comment">// create illo</span>
let illo <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> Zdog.Illustration({
  <span class="hljs-comment">// set canvas with selector</span>
  element: <span class="hljs-string">'.zdog-canvas'</span>,
});

<span class="hljs-comment">// add circle</span>
<span class="hljs-keyword">new</span> Zdog.Ellipse({
  addTo: illo,
  diameter: <span class="hljs-number">80</span>,
  stroke: <span class="hljs-number">20</span>,
  color: <span class="hljs-string">'#636'</span>,
});

<span class="hljs-comment">// update &#x26; render</span>
illo.updateRenderGraph();
</code></pre><p>当然，这样得到的画面是静止的，想要让画面动起来我们就需要不断的修改画面里的内容并且渲染，在web里最常用的就是<code>requestAnimationFrame</code> 函数了。我们只需要将上面只调用了一次的<code>updateRenderGraph</code> 函数的代码修改为如下代码，就能得到不停绕y轴旋转的图形了。</p><pre data-type="codeBlock" text="function animate() {
  // rotate illo each frame
  illo.rotate.y += 0.03;
  illo.updateRenderGraph();
  // animate next frame
  requestAnimationFrame( animate );
}
// start animation
animate();
"><code><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">animate</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-comment">// rotate illo each frame</span>
  illo.rotate.y <span class="hljs-operator">+</span><span class="hljs-operator">=</span> <span class="hljs-number">0</span><span class="hljs-number">.03</span>;
  illo.updateRenderGraph();
  <span class="hljs-comment">// animate next frame</span>
  requestAnimationFrame( animate );
}
<span class="hljs-comment">// start animation</span>
animate();
</code></pre><h2 id="h-" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">制作过程</h2><p>开始编写代码之前，我们可以简单观察一下小宇宙的App图标，它能被简单的抽象为三部分：</p><ol><li><p>中心的蓝色球体</p></li><li><p>周围的黑色环带</p></li><li><p>颜色为#fbf4f3的背景</p></li></ol><p>其中我们只需要给1和2制作3D模型即可。我们依次创建了<code>Illustration</code> , <code>Shape</code>和<code>Ellipse</code> 之后，将他们添加到画面中就能得到如下图形（图1）了。</p><p>此时我们将环带稍加旋转就会，就会出现前面提到的z-fighting问题（如图2所示），这是由于Zdog的计算比较简单，此时<code>Shape</code>和<code>Ellipse</code> 其实都是位于原点出，Zdog并不能真正知道二者谁在谁的前面。我这里采用了一个取巧的方法，即不用一个完整的<code>Ellipse</code> ，而是使用两个半环拼凑在一起，这样当环带和球体的位置关系如图2所示时，就能算出前半环，球体以及后半环三者之间的层叠关系了（如图3）。</p><p>实际代码中我们创建了一半环之后可以调用<code>copyGraph</code> 方法再原处生成一个一样的半环然后将其旋转半圈即可凑成一个环，为了让2个半环更像一个整体，我们可以将他们添加到同一个<code>Anchor</code>中（类似于group的概念）。</p><p>接下来我们为图像添加旋转效果（虽然实际上环带并不是以这种方式围绕行星转动的）。由于3D中的旋转涉及到一些图形学的知识，这里我会直接给出一种简单好理解的方式。感兴趣的同学这里可以了解一下图形学中使用<a target="_blank" rel="noopener noreferrer nofollow ugc" class="dont-break-out" href="https://zhuanlan.zhihu.com/p/362410159">四元组和欧拉角来描述旋转的方式以及万向锁问题</a>。</p><p>首先我们让整个<code>Illustration</code> 在z方向上旋转<code>Zdog.TAU / 8</code> （<code>TAU</code>是Zdog中的常数，等于2<em>π</em>），之后在每一帧都去修改圆环所在<code>Anchor</code> 在y方向上的rotation。这样对两个不同的object做旋转就能避免同时修改一个物体上z和y方向上的rotation。最后我们可以使用Zdog自带的<code>easeInOut</code> 函数让旋转看起来更加自然一些。</p><h2 id="h-" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">最终代码及成果</h2><p><a target="_blank" rel="noopener noreferrer nofollow ugc" class="dont-break-out" href="https://codepen.io/SunskyXH/pen/zYKOjGV">https://codepen.io/SunskyXH/pen/zYKOjGV</a></p><h2 id="h-" class="text-3xl font-header !mt-8 !mb-4 first:!mt-0 first:!mb-0">结尾</h2><p>Zdog简单方便易上手，但经过上面的编码之后你应该也发现了：了解学习相关的基础知识才能让我们很好得解决开发中具体遇到的问题。最近也在学习blender，用它做了一个和上面类似的模型（怎么感觉没有zdog画的好看呢），希望有机会和同事们多多交流。</p>]]></content:encoded>
            <author>sunskyxh@newsletter.paragraph.com (SunskyXH)</author>
        </item>
        <item>
            <title><![CDATA[基于WebAudio API的频率图实现]]></title>
            <link>https://paragraph.com/@sunskyxh/webaudio-api</link>
            <guid>XPke4v6AG23f2b5k407t</guid>
            <pubDate>Thu, 21 Oct 2021 03:29:14 GMT</pubDate>
            <description><![CDATA[基于WebAudio API的频率图实现基于WebAudio API的频率图实现这个需求来自于小宇宙Studio的试听页面：试听页面本身除了需要支持播放之外，还有着倍速播放以及展示频率图的需求。好在WebAudio API本身内置的功能足以覆盖这些需求。音频播放部分浏览器内置播放音频有两种方式，一是使用html5标准中的<audio>标签内置的播放功能，二则是通过WebAudio API来播放。 WebAudio提供了一系列处理音频的能力。在音频处理方面，只要是在某个AudioContext中的Node就拥有着从上一个Node中拿到采样并且处理传递给下一个Node的能力；WebAudio API也有着多种AudioSourceNode，可以通过Stream、Buffer或者MediaElement创建出对应的AudioSourceNode。 在目前的场景下，需要播放的音频是通过URL下载的，因此我们可以通过<audio>标签来完成下载以及解码音频的工作，之后可以通过MediaElementAudioSourceNode来获取解码之后的数据。大致代码如下：const audioEl...]]></description>
            <content:encoded><![CDATA[<p>基于WebAudio API的频率图实现</p><figure float="none" data-type="figure" class="img-center" style="max-width: null;"><img src="https://storage.googleapis.com/papyrus_images/8fe80aeaadc644f3cff0e4a543867b91daa57dbb4c21230b73e35ef552861460.png" alt="基于WebAudio API的频率图实现" blurdataurl="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=" nextheight="600" nextwidth="800" class="image-node embed"><figcaption HTMLAttributes="[object Object]" class="">基于WebAudio API的频率图实现</figcaption></figure><p>这个需求来自于小宇宙Studio的试听页面：试听页面本身除了需要支持播放之外，还有着倍速播放以及展示频率图的需求。好在WebAudio API本身内置的功能足以覆盖这些需求。</p><h3 id="h-" class="text-2xl font-header !mt-6 !mb-4 first:!mt-0 first:!mb-0">音频播放部分</h3><p>浏览器内置播放音频有两种方式，一是使用html5标准中的<code>&lt;audio&gt;</code>标签内置的播放功能，二则是通过WebAudio API来播放。</p><p>WebAudio提供了一系列处理音频的能力。在音频处理方面，只要是在某个AudioContext中的Node就拥有着从上一个Node中拿到采样并且处理传递给下一个Node的能力；WebAudio API也有着多种AudioSourceNode，可以通过Stream、Buffer或者MediaElement创建出对应的AudioSourceNode。</p><p>在目前的场景下，需要播放的音频是通过URL下载的，因此我们可以通过<code>&lt;audio&gt;</code>标签来完成下载以及解码音频的工作，之后可以通过<code>MediaElementAudioSourceNode</code>来获取解码之后的数据。大致代码如下：</p><pre data-type="codeBlock" text="const audioElement: HTMLAudioElement = /* 获取到audio标签 */
const audioContext = new AudioContext()
const sourceNode = MediaElementSourceNode(audioElement)
sourceNode.connect(audioContext.destination)
"><code>const audioElement: HTMLAudioElement <span class="hljs-operator">=</span> <span class="hljs-comment">/* 获取到audio标签 */</span>
const audioContext <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> AudioContext()
const sourceNode <span class="hljs-operator">=</span> MediaElementSourceNode(audioElement)
sourceNode.connect(audioContext.destination)
</code></pre><p>这样以来，只要触发audio标签的播放，音频的数据不会直接流向正常的播放流程，而且发送到了我们的AudioContext中，这里我们直接将source node和AudioContext的destination相连，音频数据最终就输出到了声卡等其他地方变成声音播放了出来；使用audio标签作为音频的来源还有一个好处就是我们可以使用audio标签自带的诸如playback rate之类的控制，这样就不需要自己重复实现一次了。</p><h3 id="h-" class="text-2xl font-header !mt-6 !mb-4 first:!mt-0 first:!mb-0">获取频率数据</h3><p>WebAudio API内置了<code>AnalyserNode</code> ，能够对输入的sample数据进行一些分析，例如能进行快速傅立叶变换从而获得输入sample的实时频率以及时域信息。只要指定了<code>fftSize</code> 然后调用对应API时传入一个<code>Uint8Array</code> 就可以获取这些数据了。简单的实现如下：</p><pre data-type="codeBlock" text="cconst audioElement: HTMLAudioElement = /* 获取到audio标签 */
const audioContext = new AudioContext()
const sourceNode = audioContext.createMediaElementSource(audioElement)
const analyserNode = audioContext.createAnalyser()
analyserNode.fftSize = 1024

// sourceNode -&gt; analyserNode -&gt; audiocContext.destination
sourceNode.connect(analyserNode)
analyserNode.connect(audioContext.destination)

const LENGTH = analyserNode.frequencyBinCount
const dataArray = new Uint8Array(LENGTH) 

// 每调用一次就会将当前的实时频率数据放入dataArray中
analyserNode.getByteFrequencyData(dataArray)
"><code>cconst audioElement: HTMLAudioElement <span class="hljs-operator">=</span> <span class="hljs-comment">/* 获取到audio标签 */</span>
const audioContext <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> AudioContext()
const sourceNode <span class="hljs-operator">=</span> audioContext.createMediaElementSource(audioElement)
const analyserNode <span class="hljs-operator">=</span> audioContext.createAnalyser()
analyserNode.fftSize <span class="hljs-operator">=</span> <span class="hljs-number">1024</span>

<span class="hljs-comment">// sourceNode -> analyserNode -> audiocContext.destination</span>
sourceNode.connect(analyserNode)
analyserNode.connect(audioContext.destination)

const LENGTH <span class="hljs-operator">=</span> analyserNode.frequencyBinCount
const dataArray <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> Uint8Array(LENGTH) 

<span class="hljs-comment">// 每调用一次就会将当前的实时频率数据放入dataArray中</span>
analyserNode.getByteFrequencyData(dataArray)
</code></pre><h3 id="h-" class="text-2xl font-header !mt-6 !mb-4 first:!mt-0 first:!mb-0">绘制频率图</h3><p>有了频率数据后，就可以开始着手绘制频率图了。音频在播放时，每一个瞬间获取到的频率/时域数据都是不一样的，因此我们可以在<code>requestAnimationFrame</code> 函数的回调中通过<code>getByteFrequencyData</code>获取一次数据，并且基于获取到的Uint8Array来进行绘制；在音频领域一般频率信息是使用直方图进行绘制的，恰好小宇宙Studio的波形样式一定程度上也能看做直方图。</p><p>如同剪辑页面波形图在绘制上做的优化，这里也可以采用先绘制一堆矩形的path然后一次填充的方式来绘图，这样每一次绘制的步骤比较少；同时由于<code>frequencyBinCount</code> (其值为<code>fftSize</code>的一半）可能会比较大，而这里的频率图对精度没有要求，我们可以将数据分组求平均值然后使用平均值作为矩形的高度。</p><p>下面是绘制的代码：</p><pre data-type="codeBlock" text="const ctx = canvas.getContext(&apos;2d&apos;)
// BAR_WIDTH为矩形条的宽度，BAR_GAP为两个矩形条之间的间隔，均为定值
const barCount = Math.floor(CANVAS_WIDTH / (BAR_WIDTH + BAR_GAP))

const draw = () =&gt; {
  analyserNode.getByteFrequencyData(dataArray)
  const chunks = chunk(Array.from(dataArray), dataArray.length / barCount)
  
  ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT)
  ctx.fillStyle = BAR_COLOR
  ctx.beginPath()
  
  for (let i = 0; i &lt; chunks.length; i++) {
    const chunk = chunks[i]
    const x = i * (BAR_WIDTH + BAR_GAP)
    const avgFrequency = sum(chunk) / chunk.length
    const barHeight = calcBarHeight(avgFrequency, CANVAS_HEIGHT)
    const y = CANVAS_HEIGHT - barHeight
    ctx.rect(x, y / 2, BAR_WIDTH, barHeight)
  }
  
  ctx.closePath()
  ctx.fill()
  requestAnimationFrame(draw)
}

requestAnimationFrame(draw)
"><code>const ctx <span class="hljs-operator">=</span> canvas.getContext(<span class="hljs-string">'2d'</span>)
<span class="hljs-comment">// BAR_WIDTH为矩形条的宽度，BAR_GAP为两个矩形条之间的间隔，均为定值</span>
const barCount <span class="hljs-operator">=</span> Math.floor(CANVAS_WIDTH <span class="hljs-operator">/</span> (BAR_WIDTH <span class="hljs-operator">+</span> BAR_GAP))

const draw <span class="hljs-operator">=</span> () <span class="hljs-operator">=</span><span class="hljs-operator">></span> {
  analyserNode.getByteFrequencyData(dataArray)
  const chunks <span class="hljs-operator">=</span> chunk(Array.from(dataArray), dataArray.<span class="hljs-built_in">length</span> <span class="hljs-operator">/</span> barCount)
  
  ctx.clearRect(<span class="hljs-number">0</span>, <span class="hljs-number">0</span>, CANVAS_WIDTH, CANVAS_HEIGHT)
  ctx.fillStyle <span class="hljs-operator">=</span> BAR_COLOR
  ctx.beginPath()
  
  <span class="hljs-keyword">for</span> (let i <span class="hljs-operator">=</span> <span class="hljs-number">0</span>; i <span class="hljs-operator">&#x3C;</span> chunks.<span class="hljs-built_in">length</span>; i<span class="hljs-operator">+</span><span class="hljs-operator">+</span>) {
    const chunk <span class="hljs-operator">=</span> chunks[i]
    const x <span class="hljs-operator">=</span> i <span class="hljs-operator">*</span> (BAR_WIDTH <span class="hljs-operator">+</span> BAR_GAP)
    const avgFrequency <span class="hljs-operator">=</span> sum(chunk) <span class="hljs-operator">/</span> chunk.<span class="hljs-built_in">length</span>
    const barHeight <span class="hljs-operator">=</span> calcBarHeight(avgFrequency, CANVAS_HEIGHT)
    const y <span class="hljs-operator">=</span> CANVAS_HEIGHT <span class="hljs-operator">-</span> barHeight
    ctx.rect(x, y <span class="hljs-operator">/</span> <span class="hljs-number">2</span>, BAR_WIDTH, barHeight)
  }
  
  ctx.closePath()
  ctx.fill()
  requestAnimationFrame(draw)
}

requestAnimationFrame(draw)
</code></pre><p>最后实际会发现效果不错，浏览器每一帧都会使用当前的实时绘制出图像，频率数据会随着音频实时变化，因此图像也会随着音频实时变化，自然又流畅。</p><p>附上这一套代码的架构图以及实际效果图：</p><figure float="none" data-type="figure" class="img-center" style="max-width: null;"><img src="https://storage.googleapis.com/papyrus_images/b6f2640f72f45c3613242fb11d982edd9303cf1dc071ddb0b9760e3b467e41a2.png" alt="" blurdataurl="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=" nextheight="600" nextwidth="800" class="image-node embed"><figcaption HTMLAttributes="[object Object]" class="hide-figcaption"></figcaption></figure><p>试听页面频率图架构</p><figure float="none" data-type="figure" class="img-center" style="max-width: null;"><img src="https://storage.googleapis.com/papyrus_images/81d3fdee78cc8f371b0e32573165bb7b0fcefeddaa3bdef01350f1a73f93cbee.gif" alt="" blurdataurl="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=" nextheight="600" nextwidth="800" class="image-node embed"><figcaption HTMLAttributes="[object Object]" class="hide-figcaption"></figcaption></figure><p>频率图实际效果</p><p>如果你想亲自看看上面提到的频率图，可以访问<a target="_blank" rel="noopener noreferrer nofollow ugc" class="dont-break-out" href="https://studio.xiaoyuzhoufm.com/view/ayamfyprac">这个页面</a> (密码是<code>QUHY</code>，点击右上角e可以切换到波形模式)。美中不足的是，由于部分WebKit内核WebAudio播放音频时会有杂音和卡顿，所以在这些浏览器（就不点名批评是谁了）上为了收听体验我们选择fallback到了通过audio播放，因此是没有显示波形图的。</p><p>最后， <code>AnalyserNode</code> 提供的能力除了上面提到的<code>getByteFrequencyData</code>之外还有其他很多可以使用的API，读到这里的大家可以试试使用 <code>getByteTimeDomainData</code> 获取时域信息，从而绘制类似示波器的图案。</p>]]></content:encoded>
            <author>sunskyxh@newsletter.paragraph.com (SunskyXH)</author>
        </item>
    </channel>
</rss>