Pixi.js 半圆柱贴图渲染
通过 Pixi.js 把一张资源图片,渲染到半圆柱的圆面上。
效果演示
核心源码
pixi v8 版本代码实现
vue
<template>
<div class="pixi-canvas-wrapper" ref="wrapper">
<div ref="canvasContainer" class="canvas-container"></div>
<div v-if="!hasImage" class="placeholder">
请点击上方按钮上传图片<br>以查看圆柱体效果
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as PIXI from 'pixi.js'
const props = defineProps<{
imageFile: File | null
}>()
const canvasContainer = ref<HTMLElement | null>(null)
const wrapper = ref<HTMLElement | null>(null)
const hasImage = ref(false)
let app: PIXI.Application | null = null
let stageContainer: PIXI.Container | null = null
// 初始化 Pixi 应用
const initPixi = async () => {
if (!canvasContainer.value) return
app = new PIXI.Application()
await app.init({
width: 800,
height: 600,
background: '#1a1a1a',
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true
})
canvasContainer.value.appendChild(app.canvas)
stageContainer = new PIXI.Container()
stageContainer.x = app.screen.width / 2
stageContainer.y = app.screen.height / 2
app.stage.addChild(stageContainer)
}
// 创建圆柱体网格
const createCylinderPlane = async (texture: PIXI.Texture) => {
if (!stageContainer || !app) return
// 清理旧内容
stageContainer.removeChildren()
// --- 配置参数 ---
const segmentsX = 40 // X轴分段数
const segmentsY = 2 // Y轴分段数
const imgWidth = texture.width
const imgHeight = texture.height
// 创建 SimplePlane
const plane = new PIXI.MeshPlane({ texture, verticesX: segmentsX + 1, verticesY: segmentsY })
// --- 数学变换 ---
const buffer = plane.geometry.getBuffer('aPosition')
const data = buffer.data as Float32Array
// 理论圆柱半径
const radius = imgWidth / Math.PI
for (let i = 0; i < data.length; i += 2) {
const originalY = data[i + 1]
const vertexIndex = i / 2
const cols = segmentsX + 1
const colIndex = vertexIndex % cols
// 归一化比例 k (0.0 ~ 1.0)
const k = colIndex / segmentsX
// 映射到角度 (-90度 ~ +90度)
const angle = (k - 0.5) * Math.PI
// 圆柱投影公式
const cylinderX = radius * Math.sin(angle)
const depthZ = radius * Math.cos(angle)
// 应用坐标
data[i] = cylinderX
// 透视效果
const perspectiveFactor = 0.85 + (depthZ / radius) * 0.15
// 调整 Y 轴
const centerY = imgHeight / 2
const distY = originalY - centerY
data[i + 1] = centerY + distY * perspectiveFactor
}
buffer.update()
// 居中平面
plane.x = 0
// 由于我们手动将顶点修改为以 0 为中心([-R, R]),
// 所以 Pivot X 应该设为 0,而不是 imgWidth/2。
// Y 轴仍然保持原始坐标系([0, H]),所以 Pivot Y 设为 imgHeight/2 以便垂直居中。
plane.pivot.set(0, imgHeight / 2)
stageContainer.addChild(plane)
// --- 自动缩放适应画布 ---
const boundsWidth = radius * 2
const boundsHeight = imgHeight
// 留出 10% 边距
const padding = 0.9
const scaleX = (app.screen.width * padding) / boundsWidth
const scaleY = (app.screen.height * padding) / boundsHeight
// 保持比例,且最大缩放为 1 (不放大)
const scale = Math.min(scaleX, scaleY, 1)
stageContainer.scale.set(scale)
}
watch(() => props.imageFile, (newFile) => {
if (newFile) {
hasImage.value = true
const reader = new FileReader()
reader.onload = async (e) => {
if (e.target?.result && typeof e.target.result === 'string') {
const texture = await PIXI.Assets.load(e.target.result)
createCylinderPlane(texture)
}
}
reader.readAsDataURL(newFile)
}
})
onMounted(() => {
initPixi()
})
onUnmounted(() => {
if (app) {
// @ts-ignore
app.destroy(true, { children: true, texture: true, baseTexture: true })
}
})
</script>
<style scoped>
.pixi-canvas-wrapper {
position: relative;
width: 100%;
height: 600px;
background: #1a1a1a;
border-radius: 8px;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
.canvas-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.placeholder {
position: absolute;
color: #fff;
text-align: center;
font-size: 18px;
pointer-events: none;
line-height: 1.5;
}
</style>pixi v4 版本代码实现
vue
<template>
<div class="pixi-canvas-wrapper" ref="wrapper">
<div ref="canvasContainer" class="canvas-container"></div>
<div v-if="!hasImage" class="placeholder">
请点击上方按钮上传图片<br>以查看 Pixi V4 圆柱体效果
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
const props = defineProps<{
imageFile: File | null
}>()
const canvasContainer = ref<HTMLElement | null>(null)
const wrapper = ref<HTMLElement | null>(null)
const hasImage = ref(false)
let PIXI: any = null
let app: any = null
let stageContainer: any = null
const initPixi = async () => {
if (!canvasContainer.value) return
if (!PIXI) {
// @ts-ignore
if (typeof window !== 'undefined' && !window.global) {
// @ts-ignore
window.global = window
}
// @ts-ignore
const pixiModule = await import('pixi.js-v4')
// Handle both default export (common in bundlers) and named export
PIXI = pixiModule.default || pixiModule
}
// @ts-ignore
app = new PIXI.Application({
width: 800,
height: 600,
backgroundColor: 0x1a1a1a,
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true
})
canvasContainer.value.appendChild(app.view)
stageContainer = new PIXI.Container()
const resolution = app.renderer.resolution || 1
stageContainer.x = (app.renderer.width / resolution) / 2
stageContainer.y = (app.renderer.height / resolution) / 2
app.stage.addChild(stageContainer)
if (props.imageFile) {
loadImage(props.imageFile)
}
}
const loadImage = (file: File) => {
if (!PIXI) return
const reader = new FileReader()
reader.onload = (e) => {
if (e.target?.result && typeof e.target.result === 'string') {
const texture = PIXI.Texture.from(e.target.result)
createCylinderMesh(texture)
}
}
reader.readAsDataURL(file)
}
/** 手动创建圆柱体 Mesh (PixiJS v4) */
const createCylinderMesh = (texture: any) => {
if (!stageContainer || !app || !PIXI) return
// 确保纹理已加载且尺寸有效
if (!texture.baseTexture.hasLoaded || texture.width <= 1 || texture.height <= 1) {
texture.once('update', () => createCylinderMesh(texture))
return
}
stageContainer.removeChildren()
const segmentsX = 40
const segmentsY = 2
const verticesX = segmentsX + 1
const verticesY = segmentsY
const imgWidth = texture.width
const imgHeight = texture.height
const radius = imgWidth / Math.PI
// 使用 Plane 自动生成拓扑结构
// @ts-ignore
const mesh = new PIXI.mesh.Plane(texture, verticesX, verticesY)
// 【防止重置】覆盖 refresh 方法,防止 Texture 更新时重置顶点
// @ts-ignore
mesh.refresh = () => {};
// @ts-ignore
mesh._refresh = () => {};
const vertices = mesh.vertices
for (let i = 0; i < vertices.length / 2; i++) {
const col = i % verticesX
const row = Math.floor(i / verticesX)
const u = col / segmentsX
const v = row / (segmentsY - 1)
const angle = (u - 0.5) * Math.PI
const cylinderX = radius * Math.sin(angle)
const depthZ = radius * Math.cos(angle)
const originalY = v * imgHeight
const centerY = imgHeight / 2
// 透视因子
const perspectiveFactor = 0.85 + (depthZ / radius) * 0.15
const distY = originalY - centerY
const finalY = centerY + distY * perspectiveFactor
vertices[i * 2] = cylinderX
vertices[i * 2 + 1] = finalY
}
mesh.x = 0
mesh.pivot.set(0, imgHeight / 2)
// 必须把 dirty 标记设为 true
// @ts-ignore
if (mesh.dirty) mesh.dirty++;
stageContainer.addChild(mesh)
// --- 缩放计算优化 ---
// 使用显式的逻辑尺寸 (800x600) 进行计算,与 initPixi 中的配置保持严格一致
// 这样可以规避 renderer 尺寸、resolution 或 DOM 尺寸在不同环境下的不确定性
const APP_WIDTH = 800
const APP_HEIGHT = 600
const boundsWidth = radius * 2
const boundsHeight = imgHeight
// 增加边距到 0.65 (留 35% 空隙),确保上下肯定能完整显示
const padding = 0.65
const scaleX = (APP_WIDTH * padding) / boundsWidth
const scaleY = (APP_HEIGHT * padding) / boundsHeight
// 保持比例缩放,且最大不超过 1
const scale = Math.min(scaleX, scaleY, 1)
stageContainer.scale.set(scale)
// 确保居中到逻辑画布中心
stageContainer.x = APP_WIDTH / 2
stageContainer.y = APP_HEIGHT / 2
}
watch(() => props.imageFile, (newFile) => {
if (newFile) {
hasImage.value = true
loadImage(newFile)
}
})
onMounted(() => {
initPixi()
})
onUnmounted(() => {
if (app) {
app.destroy(true)
}
})
</script>
<style scoped>
.pixi-canvas-wrapper {
position: relative;
width: 100%;
height: 600px;
background: #1a1a1a;
border-radius: 8px;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
.canvas-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
:deep(canvas) {
display: block;
}
.placeholder {
position: absolute;
color: #fff;
text-align: center;
font-size: 18px;
pointer-events: none;
line-height: 1.5;
}
</style>核心原理
要在 2D 屏幕上模拟半圆柱,我们需要修改网格顶点的坐标:
- X 轴映射 (Mapping): 图片的水平
坐标原本是线性的。在圆柱体上,图片是“卷”起来的。 - 原本的
(纹理坐标) 对应圆柱的角度 。 - 屏幕上的
坐标应该是 。 - 这将导致图像中间宽,两边窄(透视压缩)。
- 原本的
- Y 轴透视 (Perspective): 为了增加立体感,离屏幕越远(圆柱边缘),图像应该显得稍微小一点(近大远小)。
- 光影 (Shading): 圆柱体边缘应该比中间暗。我们会通过叠加一层带有透明度渐变的遮罩来实现伪 3D 光照。