无意中看到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的网格尺寸,用于模拟缩放。