無意中看到miro的畫布有一個無縫滾動+縮放的功能:https://miro.com/app/board/uXjVMR5OCp8=/?share_link_id=751850163166
看到“無縫滾動”這個關鍵詞,當時一開始想着是不是用類似pixi.js裏類似TilingSprite的實現方式,把網格圖片平鋪開來。但是miro畫布縮放的時候依然能保持網格線清晰,於是使用renderTexture繪製網格,並在縮放過程中動態修改網格的尺寸,重新生成renderTexture給TilingSprite使用,雖然可以做到網格線清晰,但是renderTexture在繪製邊緣線後在TilingSprite平鋪開來時,會有線段閃爍的問題,具體原因不詳,於是試着老老實實用Graphics的方式來逐行逐列繪製網格線,並通過網格間距與位移模擬縮放。
首先,我們使用pixi實現《以鼠標爲錨點平滑縮放元素》這篇博客的縮放功能,具體原理參考該博客,完整代碼如下:
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<title>縮放</title>
<style>
html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
#canvas {
display: block;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script src="./lib/pixi_v6.2.2.min.js"></script>
<script src="./lib/stats.min.js"></script>
<script>
(function () {
window.PIXI = PIXI
PIXI.utils.skipHello()
const stats = new Stats()
stats.showPanel(0) // 0: fps, 1: ms, 2: mb, 3+: custom
stats.dom.style.transformOrigin = '0 0'
stats.dom.style.transform = 'translate(0,100px) scale(1.2)'
document.body.appendChild(stats.dom)
let pageWidth = 0
let pageHeight = 0
let canvasWidth = 0
let canvasHeight = 0
let renderer = null
function onResize (e) {
pageWidth = window.innerWidth
pageHeight = window.innerHeight
canvasWidth = pageWidth
canvasHeight = pageHeight
renderer && renderer.resize(canvasWidth, canvasHeight)
}
onResize()
const $canvas = document.querySelector('#canvas')
renderer = new PIXI.Renderer({
view: $canvas,
width: canvasWidth,
height: canvasHeight,
resolution: window.devicePixelRatio, // 放大 devicePixelRatio 倍
autoDensity: true, // 縮小 devicePixelRatio 倍
backgroundAlpha: 1,
backgroundColor: 0xeef2f8,
antialias: true
})
const stage = new PIXI.Container()
stage.name = 'stage'
const ticker = new PIXI.Ticker()
let mouse = { x: 0, y: 0 }
let moving = false
let mouseDown = false
let scaling = false
let lastScale = 1
let scale = 1
let translateX = 0
let translateY = 0
let targetScale = 1
let targetTranslateX = 0
let targetTranslateY = 0
class Gay extends PIXI.Sprite {
constructor () {
super()
this.name = 'Gay'
this.texture = PIXI.Texture.from('./gay.jpg')
this.anchor.set(0.5)
this._startX = canvasWidth * 0.5
this._startY = canvasHeight * 0.5
this.dirty = false
this.position.set(this._startX, this._startY)
}
setTranslate () {
this.position.set(this._startX + translateX, this._startY + translateY)
}
setScale () {
this.scale.set(scale, scale)
this.position.x = mouse.x + (this.position.x - mouse.x) / lastScale * scale
this.position.y = mouse.y + (this.position.y - mouse.y) / lastScale * scale
this._startX = this.position.x - translateX
this._startY = this.position.y - translateY
}
}
const gay = new Gay()
stage.addChild(gay)
stage.interactive = true
let startPos = { x: 0, y: 0 }
let startMouse = { x: 0, y: 0 }
$canvas.addEventListener('mousedown', function (e) {
mouse = {
x: e.pageX,
y: e.pageY
}
mouseDown = true
moving = true
// 終止縮放
scaling = false
targetScale = scale
targetTranslateX = translateX
targetTranslateY = translateY
startMouse = {
x: mouse.x,
y: mouse.y
}
startPos = {
x: translateX,
y: translateY
}
})
$canvas.addEventListener('mousemove', function (e) {
if (mouseDown) {
mouse = {
x: e.pageX,
y: e.pageY
}
targetTranslateX = startPos.x + (mouse.x - startMouse.x)
targetTranslateY = startPos.y + (mouse.y - startMouse.y)
}
})
$canvas.addEventListener('mouseup', function (e) {
mouseDown = false
})
$canvas.onwheel = function (e) {
mouse = {
x: e.pageX,
y: e.pageY
}
scaling = true
// 終止移動
moving = false
targetTranslateX = translateX
targetTranslateY = translateY
const delta = e.wheelDelta ? e.wheelDelta : -e.deltaY
targetScale = delta > 0 ? scale * 1.4 : scale / 1.4
}
const loop = () => {
stats.begin()
if (moving) {
const deltaX = (targetTranslateX - translateX) * 0.1
const deltaY = (targetTranslateY - translateY) * 0.1
// 差值小於0.001並且鼠標擡起,標爲移動結束
if (Math.abs(deltaX) <= 0.001 && Math.abs(deltaY) <= 0.001 && !mouseDown) {
moving = false
}
else {
translateX += deltaX
translateY += deltaY
}
gay.setTranslate()
}
if (scaling) {
lastScale = scale
let deltaScale = (targetScale - scale) * 0.1
// 差值小於0.001,標爲縮放結束
if (Math.abs(deltaScale) <= 0.001) {
scaling = false
}
else {
scale += deltaScale
}
gay.setScale()
}
renderer.render(stage)
stats.end()
}
ticker.add(loop)
ticker.start()
renderer.render(stage)
// 供pixi的inspector使用
globalThis.__PIXI_STAGE__ = stage
globalThis.__PIXI_RENDERER__ = renderer
window.onresize = onResize
})()
</script>
</body>
</html>
接下來實現我們的網格背景:
class GridBackground extends PIXI.Graphics {
constructor () {
super()
this.name = 'GridBackground'
this.gridSize = 50
this._startX = 0
this._startY = 0
this.translateX = 0
this.translateY = 0
const gridVector = new PIXI.DisplayObject()
gridVector.name = 'gridVector'
this.gridVector = gridVector
this.setTranslate()
}
setVectorTranslate () {
this.gridVector.position.set(this._startX + translateX, this._startY + translateY)
}
setTranslate () {
this.setVectorTranslate()
const gridSize = this.gridSize
this.clear()
this.lineStyle(1, 0xdee0e3)
const gridVector = this.gridVector
const newTranslateX = gridVector.position.x % gridSize
const newTranslateY = gridVector.position.y % gridSize
// (i-1) 是爲了多畫一條線
// 豎向線
for (let i = 0; (i - 1) * gridSize <= canvasWidth; i++) {
this.moveTo(newTranslateX + i * gridSize, 0)
this.lineTo(newTranslateX + i * gridSize, canvasHeight)
}
// 橫向線
for (let i = 0; (i - 1) * gridSize <= canvasHeight; i++) {
this.moveTo(0, newTranslateY + i * gridSize)
this.lineTo(canvasWidth, newTranslateY + i * gridSize)
}
}
setVectorScale () {
const gridVector = this.gridVector
gridVector.scale.set(scale, scale)
gridVector.position.x = mouse.x + (gridVector.position.x - mouse.x) / lastScale * scale
gridVector.position.y = mouse.y + (gridVector.position.y - mouse.y) / lastScale * scale
this._startX = gridVector.position.x - translateX
this._startY = gridVector.position.y - translateY
}
setScale () {
this.setVectorScale()
const gridVector = this.gridVector
this.gridSize = 50 * scale
this.setTranslate()
}
}
const grid = new GridBackground()
stage.addChild(grid)
這個類與Gay類最大的區別是增加了一個gridVector屬性,用PIXI.DisplayObject來實例化,但是不添加到stage中,對gridVector的操作與gay一樣,僅僅用於計算網格的縮放與偏移量。
當gridVector移動完以後,把其position值應用給grid的繪製偏移量。
其中 const newTranslateX = gridVector.position.x % gridSize 這個方法是爲了確保繪製的偏移量始終保持在gridSize內,因爲偏移 gridSize 的倍數距離在視覺上看起來就是沒變化。
當gridVector縮放完以後,把其position值應用給grid的繪製偏移量,縮放倍數應用給grid的網格尺寸,用於模擬縮放。