feat完善三层光栅卡片预览陀螺仪,光栅卡多素材架构升级技术设计,新增资产-素材关联表

This commit is contained in:
liulong 2026-05-15 23:19:42 +08:00
parent 39209849cc
commit 00e515b3dd
23 changed files with 3151 additions and 20 deletions

View File

@ -49,6 +49,10 @@ if [[ "$(uname)" == "Darwin" ]]; then
elif [[ "$(uname)" == "Linux" ]]; then
WATCHER_TOOL="inotifywait"
WATCHER_CMD="inotifywait -r -m -e modify,create,write"
elif [[ "$(uname)" =~ ^MINGW ]] || [[ "$(uname)" =~ ^MSYS ]] || [[ "$(uname)" =~ ^CYGWIN ]]; then
# Windows via Git Bash / MSYS2 / Cygwin
WATCHER_TOOL="fswatch"
WATCHER_CMD="fswatch -r"
else
echo -e "${RED}不支持的平台${NC}"
exit 1
@ -58,6 +62,10 @@ if ! command -v "$WATCHER_TOOL" &> /dev/null; then
echo -e "${RED}缺少工具: $WATCHER_TOOL${NC}"
if [[ "$(uname)" == "Darwin" ]]; then
echo "安装方法: brew install fswatch"
elif [[ "$(uname)" =~ ^MINGW ]] || [[ "$(uname)" =~ ^MSYS ]] || [[ "$(uname)" =~ ^CYGWIN ]]; then
echo "安装方法: choco install fswatch (Chocolatey)"
echo " scoop install fswatch (Scoop)"
echo " winget install fswatch (WinGet)"
else
echo "安装方法: sudo apt install inotify-tools (Debian/Ubuntu)"
echo " sudo yum install inotify-tools (CentOS/RHEL)"
@ -259,8 +267,8 @@ start_watcher() {
--exclude='starbookService$'
fi | while read event; do
# 时间戳防抖:每次事件更新标记文件
# Darwin 不支持 date +%s%N使用 python 获取纳秒时间戳
if [[ "$(uname)" == "Darwin" ]]; then
# Darwin/Windows 不支持 date +%s%N使用 python 获取纳秒时间戳
if [[ "$(uname)" == "Darwin" ]] || [[ "$(uname)" =~ ^MINGW ]] || [[ "$(uname)" =~ ^MSYS ]] || [[ "$(uname)" =~ ^CYGWIN ]]; then
python3 -c 'import time; print(int(time.time()*1e9))' > "$restart_marker"
else
date +%s%N > "$restart_marker"
@ -279,7 +287,7 @@ start_watcher() {
continue
fi
local now
if [[ "$(uname)" == "Darwin" ]]; then
if [[ "$(uname)" == "Darwin" ]] || [[ "$(uname)" =~ ^MINGW ]] || [[ "$(uname)" =~ ^MSYS ]] || [[ "$(uname)" =~ ^CYGWIN ]]; then
now=$(python3 -c 'import time; print(int(time.time()*1e9))')
else
now=$(date +%s%N)

View File

@ -1460,3 +1460,265 @@ func (ctrl *AssetController) ImageGeneration(c *gin.Context) {
"images": result.Images,
})
}
// UploadMaterial 上传素材
func (ctrl *AssetController) UploadMaterial(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, http.StatusUnauthorized, "未授权")
return
}
starID, exists := c.Get("star_id")
if !exists {
response.Error(c, http.StatusUnauthorized, "未授权")
return
}
var req dto.UploadMaterialRequestDTO
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
"star_id": strconv.FormatInt(starID.(int64), 10),
})
w := int32(0)
h := int32(0)
if req.Width != nil {
w = int32(*req.Width)
}
if req.Height != nil {
h = int32(*req.Height)
}
resp, err := ctrl.assetService.UploadMaterial(ctx, &pbAsset.UploadMaterialRequest{
OssKey: req.OssKey,
OriginalName: req.OriginalName,
FileSize: req.FileSize,
MimeType: req.MimeType,
Width: w,
Height: h,
Hash: req.Hash,
MaterialType: req.MaterialType,
})
if err != nil {
logger.Logger.Error("UploadMaterial RPC failed", zap.Error(err))
code, msg := parseRPCError(err)
response.ErrorWithCode(c, code, msg)
return
}
if resp.Base.Code != pbCommon.StatusCode_STATUS_OK {
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
return
}
data := dto.ConvertMaterial(resp.Material)
response.Success(c, data)
}
// BindAssetMaterials 绑定资产素材
func (ctrl *AssetController) BindAssetMaterials(c *gin.Context) {
assetIDStr := c.Param("asset_id")
assetID, err := strconv.ParseInt(assetIDStr, 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "参数错误: asset_id 必须为数字")
return
}
if _, exists := c.Get("user_id"); !exists {
response.Error(c, http.StatusUnauthorized, "未授权")
return
}
var req dto.BindAssetMaterialsRequestDTO
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
materials := make([]*pbAsset.AssetMaterialRelation, 0, len(req.Materials))
for _, item := range req.Materials {
m := &pbAsset.AssetMaterialRelation{
AssetId: assetID,
MaterialId: item.MaterialID,
MaterialType: item.MaterialType,
LayerOrder: item.LayerOrder,
}
if item.PosX != nil {
m.PosX = *item.PosX
}
if item.PosY != nil {
m.PosY = *item.PosY
}
if item.Opacity != nil {
m.Opacity = *item.Opacity
}
if item.Rotation != nil {
m.Rotation = *item.Rotation
}
if item.ScaleX != nil {
m.ScaleX = *item.ScaleX
}
if item.ScaleY != nil {
m.ScaleY = *item.ScaleY
}
materials = append(materials, m)
}
resp, err := ctrl.assetService.BindAssetMaterials(ctx, &pbAsset.BindAssetMaterialsRequest{
AssetId: assetID,
Materials: materials,
})
if err != nil {
logger.Logger.Error("BindAssetMaterials RPC failed", zap.Error(err))
code, msg := parseRPCError(err)
response.ErrorWithCode(c, code, msg)
return
}
if resp.Base.Code != pbCommon.StatusCode_STATUS_OK {
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
return
}
response.Success(c, gin.H{"message": "关联成功"})
}
// GetAssetMaterials 获取资产素材列表
func (ctrl *AssetController) GetAssetMaterials(c *gin.Context) {
assetIDStr := c.Param("asset_id")
assetID, err := strconv.ParseInt(assetIDStr, 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "参数错误: asset_id 必须为数字")
return
}
if _, exists := c.Get("user_id"); !exists {
response.Error(c, http.StatusUnauthorized, "未授权")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := ctrl.assetService.GetAssetMaterials(ctx, &pbAsset.GetAssetMaterialsRequest{
AssetId: assetID,
})
if err != nil {
logger.Logger.Error("GetAssetMaterials RPC failed", zap.Error(err))
code, msg := parseRPCError(err)
response.ErrorWithCode(c, code, msg)
return
}
if resp.Base.Code != pbCommon.StatusCode_STATUS_OK {
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
return
}
data := make([]dto.AssetMaterialRelationDTO, 0, len(resp.Materials))
for _, m := range resp.Materials {
data = append(data, dto.ConvertAssetMaterialRelation(m))
}
response.Success(c, data)
}
// UpdateMaterialLayerOrder 更新图层顺序
func (ctrl *AssetController) UpdateMaterialLayerOrder(c *gin.Context) {
assetIDStr := c.Param("asset_id")
assetID, err := strconv.ParseInt(assetIDStr, 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "参数错误: asset_id 必须为数字")
return
}
if _, exists := c.Get("user_id"); !exists {
response.Error(c, http.StatusUnauthorized, "未授权")
return
}
var req dto.UpdateLayerOrderRequestDTO
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
orders := make([]*pbAsset.MaterialLayerOrderItem, 0, len(req.Orders))
for _, o := range req.Orders {
orders = append(orders, &pbAsset.MaterialLayerOrderItem{
RelationId: o.RelationID,
LayerOrder: o.LayerOrder,
})
}
resp, err := ctrl.assetService.UpdateMaterialLayerOrder(ctx, &pbAsset.UpdateMaterialLayerOrderRequest{
AssetId: assetID,
Orders: orders,
})
if err != nil {
logger.Logger.Error("UpdateMaterialLayerOrder RPC failed", zap.Error(err))
code, msg := parseRPCError(err)
response.ErrorWithCode(c, code, msg)
return
}
if resp.Base.Code != pbCommon.StatusCode_STATUS_OK {
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
return
}
response.Success(c, gin.H{"message": "更新成功"})
}
// UnbindAssetMaterial 解绑资产素材
func (ctrl *AssetController) UnbindAssetMaterial(c *gin.Context) {
relationIDStr := c.Param("relation_id")
relationID, err := strconv.ParseInt(relationIDStr, 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "参数错误: relation_id 必须为数字")
return
}
if _, exists := c.Get("user_id"); !exists {
response.Error(c, http.StatusUnauthorized, "未授权")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := ctrl.assetService.UnbindAssetMaterial(ctx, &pbAsset.UnbindAssetMaterialRequest{
RelationId: relationID,
})
if err != nil {
logger.Logger.Error("UnbindAssetMaterial RPC failed", zap.Error(err))
code, msg := parseRPCError(err)
response.ErrorWithCode(c, code, msg)
return
}
if resp.Base.Code != pbCommon.StatusCode_STATUS_OK {
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
return
}
response.Success(c, gin.H{"message": "解绑成功"})
}

View File

@ -27,6 +27,69 @@ func ConvertCreateMintOrderResponse(pbResp *pbAsset.CreateMintOrderResponse) *Cr
return dto
}
// ========== 素材相关转换 ==========
// ConvertMaterial 转换素材信息
func ConvertMaterial(pbMaterial *pbAsset.Material) MaterialDTO {
dto := MaterialDTO{
OssKey: pbMaterial.OssKey,
OriginalName: pbMaterial.OriginalName,
FileSize: pbMaterial.FileSize,
MimeType: pbMaterial.MimeType,
Hash: pbMaterial.Hash,
CreatedBy: pbMaterial.CreatedBy,
StarID: pbMaterial.StarId,
CreatedAt: pbMaterial.CreatedAt,
}
if pbMaterial.MaterialId > 0 {
dto.MaterialID = pbMaterial.MaterialId
}
if pbMaterial.Width > 0 {
w := int(pbMaterial.Width)
dto.Width = &w
}
if pbMaterial.Height > 0 {
h := int(pbMaterial.Height)
dto.Height = &h
}
return dto
}
// ConvertAssetMaterialRelation 转换资产-素材关联
func ConvertAssetMaterialRelation(pbRel *pbAsset.AssetMaterialRelation) AssetMaterialRelationDTO {
dto := AssetMaterialRelationDTO{
RelationID: pbRel.RelationId,
AssetID: pbRel.AssetId,
MaterialID: pbRel.MaterialId,
MaterialType: pbRel.MaterialType,
LayerOrder: pbRel.LayerOrder,
MaterialURLSigned: pbRel.MaterialUrlSigned,
}
if pbRel.PosX != 0 || pbRel.PosY != 0 {
px := pbRel.PosX
py := pbRel.PosY
dto.PosX = &px
dto.PosY = &py
}
if pbRel.Opacity != 0 {
o := pbRel.Opacity
dto.Opacity = &o
}
if pbRel.Rotation != 0 {
r := pbRel.Rotation
dto.Rotation = &r
}
if pbRel.ScaleX != 0 {
sx := pbRel.ScaleX
dto.ScaleX = &sx
}
if pbRel.ScaleY != 0 {
sy := pbRel.ScaleY
dto.ScaleY = &sy
}
return dto
}
// ConvertMintOrder 转换铸造订单
func ConvertMintOrder(pbOrder *pbAsset.MintOrder) MintOrderDTO {
dto := MintOrderDTO{

View File

@ -167,3 +167,79 @@ type GetMintOrderResponseDTO struct {
Order MintOrderDTO `json:"order"` // 订单信息
Asset *AssetDTO `json:"asset,omitempty"` // 关联的资产信息(如果存在)
}
// ========== 素材相关 DTO ==========
// MaterialDTO 素材信息
type MaterialDTO struct {
MaterialID int64 `json:"material_id"`
OssKey string `json:"oss_key"`
OriginalName string `json:"original_name"`
FileSize int64 `json:"file_size"`
MimeType string `json:"mime_type"`
Width *int `json:"width"`
Height *int `json:"height"`
Hash string `json:"hash"`
CreatedBy int64 `json:"created_by"`
StarID int64 `json:"star_id"`
CreatedAt int64 `json:"created_at"`
}
// AssetMaterialRelationDTO 资产-素材关联信息
type AssetMaterialRelationDTO struct {
RelationID int64 `json:"relation_id"`
AssetID int64 `json:"asset_id"`
MaterialID int64 `json:"material_id"`
MaterialType string `json:"material_type"`
LayerOrder int32 `json:"layer_order"`
MaterialURLSigned string `json:"material_url_signed,omitempty"`
PosX *float64 `json:"pos_x"`
PosY *float64 `json:"pos_y"`
Opacity *float64 `json:"opacity"`
Rotation *float64 `json:"rotation"`
ScaleX *float64 `json:"scale_x"`
ScaleY *float64 `json:"scale_y"`
Width *int `json:"width"`
Height *int `json:"height"`
}
// UploadMaterialRequestDTO 上传素材请求
type UploadMaterialRequestDTO struct {
OssKey string `json:"oss_key" binding:"required"`
OriginalName string `json:"original_name" binding:"required"`
FileSize int64 `json:"file_size" binding:"required"`
MimeType string `json:"mime_type" binding:"required"`
Width *int `json:"width"`
Height *int `json:"height"`
Hash string `json:"hash" binding:"required"`
MaterialType string `json:"material_type"`
}
// BindAssetMaterialsRequestDTO 绑定资产素材请求
type BindAssetMaterialsRequestDTO struct {
Materials []BindAssetMaterialItemDTO `json:"materials" binding:"required"`
}
// BindAssetMaterialItemDTO 单个绑定项
type BindAssetMaterialItemDTO struct {
MaterialID int64 `json:"material_id" binding:"required"`
MaterialType string `json:"material_type" binding:"required"`
LayerOrder int32 `json:"layer_order"`
PosX *float64 `json:"pos_x"`
PosY *float64 `json:"pos_y"`
Opacity *float64 `json:"opacity"`
Rotation *float64 `json:"rotation"`
ScaleX *float64 `json:"scale_x"`
ScaleY *float64 `json:"scale_y"`
}
// UpdateLayerOrderRequestDTO 更新图层顺序请求
type UpdateLayerOrderRequestDTO struct {
Orders []LayerOrderItemDTO `json:"orders" binding:"required"`
}
// LayerOrderItemDTO 图层顺序项
type LayerOrderItemDTO struct {
RelationID int64 `json:"relation_id" binding:"required"`
LayerOrder int32 `json:"layer_order"`
}

View File

@ -194,6 +194,14 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
assets.GET("/me/items", assetCtrl.GetMyAssets) // 获取我的藏品列表
assets.GET("/:asset_id", assetCtrl.GetAsset) // 获取资产详情
assets.GET("/:asset_id/status", assetCtrl.GetAssetStatus) // 查询上链状态
// 素材管理
materials := assets.Group("/materials")
materials.POST("/upload", assetCtrl.UploadMaterial)
assets.POST("/:asset_id/materials", assetCtrl.BindAssetMaterials)
assets.GET("/:asset_id/materials", assetCtrl.GetAssetMaterials)
assets.PUT("/:asset_id/materials/layer-order", assetCtrl.UpdateMaterialLayerOrder)
assets.DELETE("/materials/:relation_id", assetCtrl.UnbindAssetMaterial)
}
// 展馆相关路由(需要认证)

View File

@ -0,0 +1,62 @@
package models
import (
"time"
"gorm.io/gorm"
)
// Material 素材表模型
type Material struct {
ID int64 `gorm:"primaryKey;autoIncrement;column:id"`
OssKey string `gorm:"type:varchar(255);not null;uniqueIndex;column:oss_key"`
OriginalName string `gorm:"type:varchar(255);not null;column:original_name"`
FileSize int64 `gorm:"not null;column:file_size"`
MimeType string `gorm:"type:varchar(100);not null;column:mime_type"`
Width *int `gorm:"column:width"`
Height *int `gorm:"column:height"`
Hash string `gorm:"type:varchar(64);not null;index;column:hash"`
CreatedBy int64 `gorm:"not null;index;column:created_by"`
StarID int64 `gorm:"not null;index;column:star_id"`
CreatedAt int64 `gorm:"not null;column:created_at"`
UpdatedAt int64 `gorm:"not null;column:updated_at"`
DeletedAt *int64 `gorm:"index;column:deleted_at"`
}
func (Material) TableName() string { return "materials" }
func (m *Material) BeforeCreate(tx *gorm.DB) error {
now := time.Now().UnixMilli()
m.CreatedAt = now
m.UpdatedAt = now
return nil
}
func (m *Material) BeforeUpdate(tx *gorm.DB) error {
m.UpdatedAt = time.Now().UnixMilli()
return nil
}
// AssetMaterialRelation 资产-素材关联表模型
type AssetMaterialRelation struct {
ID int64 `gorm:"primaryKey;autoIncrement;column:id"`
AssetID int64 `gorm:"not null;index:idx_amr_asset_id;column:asset_id"`
MaterialID int64 `gorm:"not null;index:idx_amr_material_id;column:material_id"`
MaterialType string `gorm:"type:varchar(50);not null;column:material_type"`
LayerOrder int `gorm:"not null;default:0;column:layer_order"`
PosX *float64 `gorm:"column:pos_x"`
PosY *float64 `gorm:"column:pos_y"`
Opacity *float64 `gorm:"default:1.0;column:opacity"`
Rotation *float64 `gorm:"default:0;column:rotation"`
ScaleX *float64 `gorm:"default:1.0;column:scale_x"`
ScaleY *float64 `gorm:"default:1.0;column:scale_y"`
Version int `gorm:"not null;default:1;column:version"`
CreatedAt int64 `gorm:"not null;column:created_at"`
UpdatedAt int64 `gorm:"not null;column:updated_at"`
DeletedAt *int64 `gorm:"index;column:deleted_at"`
Asset Asset `gorm:"foreignKey:AssetID;references:ID;constraint:OnDelete:CASCADE"`
Material Material `gorm:"foreignKey:MaterialID;references:ID;constraint:OnDelete:RESTRICT"`
}
func (AssetMaterialRelation) TableName() string { return "asset_material_relations" }

View File

@ -310,6 +310,100 @@ message GetAssetForRPCResponse {
bool is_active = 6; //
}
// ==================== ====================
//
message Material {
int64 material_id = 1;
string oss_key = 2;
string original_name = 3;
int64 file_size = 4;
string mime_type = 5;
int32 width = 6;
int32 height = 7;
string hash = 8;
int64 created_by = 9;
int64 star_id = 10;
int64 created_at = 11;
}
// -
message AssetMaterialRelation {
int64 relation_id = 1;
int64 asset_id = 2;
int64 material_id = 3;
string material_type = 4;
int32 layer_order = 5;
string material_url_signed = 6;
double pos_x = 7;
double pos_y = 8;
double opacity = 9;
double rotation = 10;
double scale_x = 11;
double scale_y = 12;
}
//
message UploadMaterialRequest {
string oss_key = 1;
string original_name = 2;
int64 file_size = 3;
string mime_type = 4;
int32 width = 5;
int32 height = 6;
string hash = 7;
string material_type = 8;
}
message UploadMaterialResponse {
topfans.common.BaseResponse base = 1;
Material material = 2;
}
//
message BindAssetMaterialsRequest {
int64 asset_id = 1;
repeated AssetMaterialRelation materials = 2;
}
message BindAssetMaterialsResponse {
topfans.common.BaseResponse base = 1;
}
//
message GetAssetMaterialsRequest {
int64 asset_id = 1;
}
message GetAssetMaterialsResponse {
topfans.common.BaseResponse base = 1;
repeated AssetMaterialRelation materials = 2;
}
//
message UpdateMaterialLayerOrderRequest {
int64 asset_id = 1;
repeated MaterialLayerOrderItem orders = 2;
}
message MaterialLayerOrderItem {
int64 relation_id = 1;
int32 layer_order = 2;
}
message UpdateMaterialLayerOrderResponse {
topfans.common.BaseResponse base = 1;
}
//
message UnbindAssetMaterialRequest {
int64 relation_id = 1;
}
message UnbindAssetMaterialResponse {
topfans.common.BaseResponse base = 1;
}
// ==================== ====================
service AssetService {
@ -384,6 +478,21 @@ service AssetService {
// RPCGallery Service调用便
rpc ClearAssetLikeRecords(ClearAssetLikeRecordsRequest) returns (ClearAssetLikeRecordsResponse);
//
rpc UploadMaterial(UploadMaterialRequest) returns (UploadMaterialResponse);
//
rpc BindAssetMaterials(BindAssetMaterialsRequest) returns (BindAssetMaterialsResponse);
//
rpc GetAssetMaterials(GetAssetMaterialsRequest) returns (GetAssetMaterialsResponse);
//
rpc UpdateMaterialLayerOrder(UpdateMaterialLayerOrderRequest) returns (UpdateMaterialLayerOrderResponse);
//
rpc UnbindAssetMaterial(UnbindAssetMaterialRequest) returns (UnbindAssetMaterialResponse);
}
// RPCGallery Service调用

View File

@ -110,6 +110,8 @@ func main() {
mintOrderRepo := repository.NewMintOrderRepository(database.GetDB())
assetLikeRepo := repository.NewAssetLikeRepository(database.GetDB())
rankingRepo := repository.NewRankingRepository(database.GetDB())
materialRepo := repository.NewMaterialRepository(database.GetDB())
relationRepo := repository.NewAssetMaterialRelationRepository(database.GetDB())
logger.Logger.Info("Repository layer initialized")
// 创建 Dubbo 客户端
@ -134,10 +136,11 @@ func main() {
mintService := service.NewMintService(assetRepo, mintOrderRepo, userClient, database.GetDB(), config.GlobalAssetConfig, registryRepo)
assetLikeService := service.NewAssetLikeService(assetRepo, assetLikeRepo, database.GetDB())
rankingService := service.NewRankingService(rankingRepo, userClient)
materialService := service.NewMaterialService(materialRepo, relationRepo)
logger.Logger.Info("Service layer initialized")
// 创建 Provider 层实例
assetProvider := provider.NewAssetProvider(assetService, mintService, assetLikeService)
assetProvider := provider.NewAssetProvider(assetService, mintService, assetLikeService, materialService)
rankingProvider := provider.NewRankingProvider(rankingService)
logger.Logger.Info("Provider layer initialized")
@ -194,6 +197,8 @@ func autoMigrate() error {
&models.Asset{},
&models.MintOrder{},
&models.AssetLike{},
&models.Material{},
&models.AssetMaterialRelation{},
}
for _, table := range tables {

View File

@ -21,17 +21,19 @@ type AssetProvider struct {
assetService service.AssetService
mintService service.MintService
assetLikeService *service.AssetLikeService
materialService *service.MaterialService
}
// 确保 AssetProvider 实现了 AssetServiceHandler 接口
var _ pb.AssetServiceHandler = (*AssetProvider)(nil)
// NewAssetProvider 创建资产服务Provider实例
func NewAssetProvider(assetService service.AssetService, mintService service.MintService, assetLikeService *service.AssetLikeService) *AssetProvider {
func NewAssetProvider(assetService service.AssetService, mintService service.MintService, assetLikeService *service.AssetLikeService, materialService *service.MaterialService) *AssetProvider {
return &AssetProvider{
assetService: assetService,
mintService: mintService,
assetLikeService: assetLikeService,
materialService: materialService,
}
}

View File

@ -0,0 +1,199 @@
package provider
import (
"context"
pb "github.com/topfans/backend/pkg/proto/asset"
pbCommon "github.com/topfans/backend/pkg/proto/common"
"github.com/topfans/backend/pkg/models"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/services/assetService/service"
"go.uber.org/zap"
)
// UploadMaterial 上传素材
func (p *AssetProvider) UploadMaterial(ctx context.Context, req *pb.UploadMaterialRequest) (*pb.UploadMaterialResponse, error) {
userID, starID, err := extractUserInfoFromDubboAttachments(ctx)
if err != nil {
return &pb.UploadMaterialResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED, Message: "unauthorized"},
}, err
}
material := &models.Material{
OssKey: req.OssKey,
OriginalName: req.OriginalName,
FileSize: req.FileSize,
MimeType: req.MimeType,
Hash: req.Hash,
CreatedBy: userID,
StarID: starID,
}
if req.Width > 0 {
w := int(req.Width)
material.Width = &w
}
if req.Height > 0 {
h := int(req.Height)
material.Height = &h
}
result, err := p.materialService.UploadMaterial(material)
if err != nil {
logger.Logger.Error("UploadMaterial failed", zap.Error(err))
return &pb.UploadMaterialResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL, Message: err.Error()},
}, err
}
return &pb.UploadMaterialResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK, Message: "ok"},
Material: &pb.Material{
MaterialId: result.ID,
OssKey: result.OssKey,
OriginalName: result.OriginalName,
FileSize: result.FileSize,
MimeType: result.MimeType,
Hash: result.Hash,
CreatedBy: result.CreatedBy,
StarId: result.StarID,
CreatedAt: result.CreatedAt,
},
}, nil
}
// BindAssetMaterials 绑定资产素材
func (p *AssetProvider) BindAssetMaterials(ctx context.Context, req *pb.BindAssetMaterialsRequest) (*pb.BindAssetMaterialsResponse, error) {
if _, _, err := extractUserInfoFromDubboAttachments(ctx); err != nil {
return &pb.BindAssetMaterialsResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED, Message: "unauthorized"},
}, err
}
items := make([]service.BindMaterialItem, 0, len(req.Materials))
for _, m := range req.Materials {
items = append(items, service.BindMaterialItem{
MaterialID: m.MaterialId,
MaterialType: m.MaterialType,
LayerOrder: m.LayerOrder,
PosX: doublePtr(m.PosX),
PosY: doublePtr(m.PosY),
Opacity: doublePtr(m.Opacity),
Rotation: doublePtr(m.Rotation),
ScaleX: doublePtr(m.ScaleX),
ScaleY: doublePtr(m.ScaleY),
})
}
if _, err := p.materialService.BindMaterials(req.AssetId, items); err != nil {
logger.Logger.Error("BindAssetMaterials failed", zap.Error(err))
return &pb.BindAssetMaterialsResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL, Message: err.Error()},
}, err
}
return &pb.BindAssetMaterialsResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK, Message: "ok"},
}, nil
}
// GetAssetMaterials 获取资产素材列表
func (p *AssetProvider) GetAssetMaterials(ctx context.Context, req *pb.GetAssetMaterialsRequest) (*pb.GetAssetMaterialsResponse, error) {
if _, _, err := extractUserInfoFromDubboAttachments(ctx); err != nil {
return &pb.GetAssetMaterialsResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED, Message: "unauthorized"},
}, err
}
relations, err := p.materialService.GetAssetMaterials(req.AssetId)
if err != nil {
return &pb.GetAssetMaterialsResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL, Message: err.Error()},
}, err
}
materials := make([]*pb.AssetMaterialRelation, 0, len(relations))
for _, rel := range relations {
pbRel := &pb.AssetMaterialRelation{
RelationId: rel.ID,
AssetId: rel.AssetID,
MaterialId: rel.MaterialID,
MaterialType: rel.MaterialType,
LayerOrder: int32(rel.LayerOrder),
}
if rel.PosX != nil {
pbRel.PosX = *rel.PosX
}
if rel.PosY != nil {
pbRel.PosY = *rel.PosY
}
if rel.Opacity != nil {
pbRel.Opacity = *rel.Opacity
}
if rel.Rotation != nil {
pbRel.Rotation = *rel.Rotation
}
if rel.ScaleX != nil {
pbRel.ScaleX = *rel.ScaleX
}
if rel.ScaleY != nil {
pbRel.ScaleY = *rel.ScaleY
}
materials = append(materials, pbRel)
}
return &pb.GetAssetMaterialsResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK, Message: "ok"},
Materials: materials,
}, nil
}
// UpdateMaterialLayerOrder 更新图层顺序
func (p *AssetProvider) UpdateMaterialLayerOrder(ctx context.Context, req *pb.UpdateMaterialLayerOrderRequest) (*pb.UpdateMaterialLayerOrderResponse, error) {
if _, _, err := extractUserInfoFromDubboAttachments(ctx); err != nil {
return &pb.UpdateMaterialLayerOrderResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED, Message: "unauthorized"},
}, err
}
orders := make(map[int64]int, len(req.Orders))
for _, o := range req.Orders {
orders[o.RelationId] = int(o.LayerOrder)
}
if err := p.materialService.UpdateLayerOrder(req.AssetId, orders); err != nil {
return &pb.UpdateMaterialLayerOrderResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL, Message: err.Error()},
}, err
}
return &pb.UpdateMaterialLayerOrderResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK, Message: "ok"},
}, nil
}
// UnbindAssetMaterial 解绑素材
func (p *AssetProvider) UnbindAssetMaterial(ctx context.Context, req *pb.UnbindAssetMaterialRequest) (*pb.UnbindAssetMaterialResponse, error) {
if _, _, err := extractUserInfoFromDubboAttachments(ctx); err != nil {
return &pb.UnbindAssetMaterialResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_UNAUTHORIZED, Message: "unauthorized"},
}, err
}
if err := p.materialService.UnbindMaterial(req.RelationId); err != nil {
return &pb.UnbindAssetMaterialResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL, Message: err.Error()},
}, err
}
return &pb.UnbindAssetMaterialResponse{
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK, Message: "ok"},
}, nil
}
func doublePtr(v float64) *float64 {
if v == 0 {
return nil
}
return &v
}

View File

@ -0,0 +1,70 @@
package repository
import (
"time"
"github.com/topfans/backend/pkg/models"
"gorm.io/gorm"
)
// AssetMaterialRelationRepository 资产-素材关联数据访问层
type AssetMaterialRelationRepository struct {
db *gorm.DB
}
// NewAssetMaterialRelationRepository 创建关联 Repository
func NewAssetMaterialRelationRepository(db *gorm.DB) *AssetMaterialRelationRepository {
return &AssetMaterialRelationRepository{db: db}
}
// BatchCreate 批量创建关联记录
func (r *AssetMaterialRelationRepository) BatchCreate(relations []*models.AssetMaterialRelation) error {
if len(relations) == 0 {
return nil
}
return r.db.Create(&relations).Error
}
// FindByAssetID 查询资产的所有素材关联(按 layer_order 排序)
func (r *AssetMaterialRelationRepository) FindByAssetID(assetID int64) ([]*models.AssetMaterialRelation, error) {
var relations []*models.AssetMaterialRelation
err := r.db.
Preload("Material").
Where("asset_id = ? AND deleted_at IS NULL", assetID).
Order("layer_order ASC").
Find(&relations).Error
if err != nil {
return nil, err
}
return relations, nil
}
// UpdateLayerOrder 批量更新图层顺序
func (r *AssetMaterialRelationRepository) UpdateLayerOrder(assetID int64, orders map[int64]int) error {
return r.db.Transaction(func(tx *gorm.DB) error {
for relationID, layerOrder := range orders {
if err := tx.Model(&models.AssetMaterialRelation{}).
Where("id = ? AND asset_id = ? AND deleted_at IS NULL", relationID, assetID).
Update("layer_order", layerOrder).Error; err != nil {
return err
}
}
return nil
})
}
// SoftDelete 软删除关联记录
func (r *AssetMaterialRelationRepository) SoftDelete(relationID int64) error {
now := time.Now().UnixMilli()
return r.db.Model(&models.AssetMaterialRelation{}).
Where("id = ? AND deleted_at IS NULL", relationID).
Update("deleted_at", now).Error
}
// SoftDeleteByAssetID 软删除资产的所有关联记录
func (r *AssetMaterialRelationRepository) SoftDeleteByAssetID(assetID int64) error {
now := time.Now().UnixMilli()
return r.db.Model(&models.AssetMaterialRelation{}).
Where("asset_id = ? AND deleted_at IS NULL", assetID).
Update("deleted_at", now).Error
}

View File

@ -0,0 +1,49 @@
package repository
import (
"time"
"github.com/topfans/backend/pkg/models"
"gorm.io/gorm"
)
// MaterialRepository 素材数据访问层
type MaterialRepository struct {
db *gorm.DB
}
// NewMaterialRepository 创建素材 Repository
func NewMaterialRepository(db *gorm.DB) *MaterialRepository {
return &MaterialRepository{db: db}
}
// Create 创建素材记录
func (r *MaterialRepository) Create(material *models.Material) error {
return r.db.Create(material).Error
}
// FindByOssKey 根据 OSS Key 查找
func (r *MaterialRepository) FindByOssKey(ossKey string) (*models.Material, error) {
var m models.Material
err := r.db.Where("oss_key = ? AND deleted_at IS NULL", ossKey).First(&m).Error
if err != nil {
return nil, err
}
return &m, nil
}
// FindByHash 根据哈希查找(去重用)
func (r *MaterialRepository) FindByHash(hash string, starID int64) (*models.Material, error) {
var m models.Material
err := r.db.Where("hash = ? AND star_id = ? AND deleted_at IS NULL", hash, starID).First(&m).Error
if err != nil {
return nil, err
}
return &m, nil
}
// SoftDelete 软删除素材
func (r *MaterialRepository) SoftDelete(materialID int64) error {
now := time.Now().UnixMilli()
return r.db.Model(&models.Material{}).Where("id = ?", materialID).Update("deleted_at", now).Error
}

View File

@ -0,0 +1,525 @@
# 光栅卡多素材架构升级技术设计
> **创建日期:** 2026-05-15
> **项目:** TopFans 光栅卡铸造流程
> **服务:** assetService (Go Dubbo-go) + 前端铸爱模块
> **状态:** 审核通过,待开发实施
> **版本:** v1.0
---
## 一、背景与问题确认
### 1.1 当前故障
**问题定性:光栅卡铸造流程中,背景图从未被持久化到后端。确认铸造时仅上传主体图,背景图永久丢失。**
```
create.vue (双图上传) → buildCraftFormData() → CASTLOVE_FORM_KEY → ...
✅ bgPath / subjectPath 完整传递到本地 Storage
✅ buildLenticularLayersTwo(bgPath, subjectPath) 正确构建图层
❌ handleCraftMint() 只取 mid 层 → submitCraftMintFromPath(单 imagePath)
❌ 后端 material_url 单字段 → 背景图丢失
```
### 1.2 根本技术缺陷
现有 `assets.material_url` 单字段存储存在以下瓶颈:
| 缺陷 | 说明 |
|------|------|
| 无法支撑多素材 | 单一字段存储,多素材需内嵌 JSON受限于 `varchar(500)`,最多 3-4 个 URL |
| 数据语义模糊 | 无法区分主图、背景、遮罩、特效等不同角色 |
| 查询效率低下 | 无法针对特定素材类型建立索引 |
| 复用能力缺失 | 素材无法被多个资产共享 |
| 长期扩展性差 | 未来 3 年单资产素材数预计增长至 30+,现有模型无法承载 |
---
## 二、方案设计:新增资产-素材关联表
### 2.1 方案概述
采用新增 `materials`(素材主表)+ `asset_material_relations`(资产-素材关联表)的方案,彻底解耦素材与资产的强绑定关系。
### 2.2 表关系定义
```
assets ◄── 1:N ──► asset_material_relations ◄── N:1 ──► materials
```
- **资产主表 ↔ 关联表**:一对多关系,单个资产可绑定多个素材关联记录
- **素材主表 ↔ 关联表**:多对多关系,单个素材可被多个资产关联复用
### 2.3 数据库模型设计
#### 2.3.1 素材主表materials
```sql
CREATE TABLE materials (
id BIGSERIAL PRIMARY KEY,
oss_key VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
file_size BIGINT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
width INT,
height INT,
hash VARCHAR(64) NOT NULL,
created_by BIGINT NOT NULL,
star_id BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
deleted_at BIGINT
);
CREATE UNIQUE INDEX uk_materials_oss_key ON materials(oss_key);
CREATE INDEX idx_materials_hash ON materials(hash);
CREATE INDEX idx_materials_created_by ON materials(created_by);
CREATE INDEX idx_materials_star_id ON materials(star_id);
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | `BIGSERIAL` | 主键 |
| `oss_key` | `VARCHAR(255) UNIQUE` | OSS 对象唯一标识 |
| `original_name` | `VARCHAR(255)` | 原始文件名 |
| `file_size` | `BIGINT` | 文件大小(字节) |
| `mime_type` | `VARCHAR(100)` | MIME 类型 |
| `width / height` | `INT` | 图片尺寸 |
| `hash` | `VARCHAR(64)` | 文件 SHA256 哈希,用于去重 |
| `created_by` | `BIGINT` | 创建者用户 ID |
| `star_id` | `BIGINT` | 多星数据隔离 |
| `created_at / updated_at` | `BIGINT` | 毫秒时间戳 |
| `deleted_at` | `BIGINT` | 软删除时间戳 |
#### 2.3.2 资产-素材关联表asset_material_relations
```sql
CREATE TABLE asset_material_relations (
id BIGSERIAL PRIMARY KEY,
asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
material_id BIGINT NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
material_type VARCHAR(50) NOT NULL,
layer_order INT NOT NULL DEFAULT 0,
-- 渲染定位字段NULL = 拉伸填满容器)
pos_x DOUBLE PRECISION, -- 距左上角 X 偏移量pxNULL=拉伸模式
pos_y DOUBLE PRECISION, -- 距左上角 Y 偏移量pxNULL=拉伸模式
opacity DOUBLE PRECISION DEFAULT 1.0, -- 不透明度 0~1
rotation DOUBLE PRECISION DEFAULT 0, -- 旋转角度(度),正值为顺时针
scale_x DOUBLE PRECISION DEFAULT 1.0, -- 水平缩放比例
scale_y DOUBLE PRECISION DEFAULT 1.0, -- 垂直缩放比例
version INT NOT NULL DEFAULT 1,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
deleted_at BIGINT
);
```
**索引与约束:**
```sql
CREATE INDEX idx_amr_asset_id ON asset_material_relations(asset_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_amr_material_id ON asset_material_relations(material_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_amr_asset_type_layer ON asset_material_relations(asset_id, material_type, layer_order) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uk_amr_asset_type_active ON asset_material_relations(asset_id, material_type) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uk_amr_asset_layer_active ON asset_material_relations(asset_id, layer_order) WHERE deleted_at IS NULL;
```
**字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `asset_id` | `BIGINT FK` | 关联资产 ID |
| `material_id` | `BIGINT FK` | 关联素材 ID |
| `material_type` | `VARCHAR(50)` | 素材角色:`main`/`bg`/`star_map`/`mask`/`effect` 等 |
| `layer_order` | `INT` | 图层渲染顺序,数值越小越靠下 |
| `pos_x / pos_y` | `DOUBLE PRECISION` | 距左上角偏移量pxNULL 为拉伸填满容器模式 |
| `opacity` | `DOUBLE PRECISION` | 不透明度 0~1默认 1 |
| `rotation` | `DOUBLE PRECISION` | 旋转角度(度),正值为顺时针,默认 0 |
| `scale_x / scale_y` | `DOUBLE PRECISION` | 缩放比例,默认 1 |
| `version` | `INT` | 素材版本号,乐观锁控制并发 |
| `deleted_at` | `BIGINT` | 软删除时间戳 |
### 2.4 Go Model 定义
```go
// Material 素材表模型
type Material struct {
ID int64 `gorm:"primaryKey;autoIncrement;column:id"`
OssKey string `gorm:"type:varchar(255);not null;uniqueIndex;column:oss_key"`
OriginalName string `gorm:"type:varchar(255);not null;column:original_name"`
FileSize int64 `gorm:"not null;column:file_size"`
MimeType string `gorm:"type:varchar(100);not null;column:mime_type"`
Width *int `gorm:"column:width"`
Height *int `gorm:"column:height"`
Hash string `gorm:"type:varchar(64);not null;index;column:hash"`
CreatedBy int64 `gorm:"not null;index;column:created_by"`
StarID int64 `gorm:"not null;index;column:star_id"`
CreatedAt int64 `gorm:"not null;column:created_at"`
UpdatedAt int64 `gorm:"not null;column:updated_at"`
DeletedAt *int64 `gorm:"index;column:deleted_at"`
}
func (Material) TableName() string { return "materials" }
func (m *Material) BeforeCreate(tx *gorm.DB) error {
now := time.Now().UnixMilli()
m.CreatedAt = now
m.UpdatedAt = now
return nil
}
func (m *Material) BeforeUpdate(tx *gorm.DB) error {
m.UpdatedAt = time.Now().UnixMilli()
return nil
}
```
```go
// AssetMaterialRelation 资产-素材关联表模型
type AssetMaterialRelation struct {
ID int64 `gorm:"primaryKey;autoIncrement;column:id"`
AssetID int64 `gorm:"not null;index:idx_amr_asset_id;column:asset_id"`
MaterialID int64 `gorm:"not null;index:idx_amr_material_id;column:material_id"`
MaterialType string `gorm:"type:varchar(50);not null;column:material_type"`
LayerOrder int `gorm:"not null;default:0;column:layer_order"`
PosX *float64 `gorm:"column:pos_x"`
PosY *float64 `gorm:"column:pos_y"`
Opacity *float64 `gorm:"default:1.0;column:opacity"`
Rotation *float64 `gorm:"default:0;column:rotation"`
ScaleX *float64 `gorm:"default:1.0;column:scale_x"`
ScaleY *float64 `gorm:"default:1.0;column:scale_y"`
Version int `gorm:"not null;default:1;column:version"`
CreatedAt int64 `gorm:"not null;column:created_at"`
UpdatedAt int64 `gorm:"not null;column:updated_at"`
DeletedAt *int64 `gorm:"index;column:deleted_at"`
Asset Asset `gorm:"foreignKey:AssetID;references:ID;constraint:OnDelete:CASCADE"`
Material Material `gorm:"foreignKey:MaterialID;references:ID;constraint:OnDelete:RESTRICT"`
}
func (AssetMaterialRelation) TableName() string { return "asset_material_relations" }
```
---
## 三、数据一致性保障方案
### 3.1 事务隔离级别与约束规则
- **隔离级别**:所有跨表操作用 `REPEATABLE READ`
- **级联规则**:资产删除时 `ON DELETE CASCADE` 级联删除关联记录;素材删除时 `ON DELETE RESTRICT` 禁止删除被引用的素材
- **唯一约束**:通过 PostgreSQL 部分唯一索引 `WHERE deleted_at IS NULL` 实现,允许同一资产同一类型在不同版本间切换
### 3.2 异常场景回滚机制
| 异常场景 | 回滚机制 |
|---------|---------|
| 素材上传失败 | 回滚 `materials` 插入记录,删除 OSS 部分文件 |
| 关联绑定异常 | 回滚关联表插入,保留素材记录用于后续复用 |
| 资产删除中断 | 事务回滚,恢复资产和关联记录 |
| 素材版本更新冲突 | 乐观锁(`version` 字段),冲突时提示用户刷新 |
| 跨节点事务超时 | SAGA 模式,超时后执行补偿操作 |
| 非法参数注入 | 入参校验失败直接拒绝,不执行 DB 操作 |
| 脏数据写入 | 外键 + 唯一索引双重阻拦 |
| 并发绑定锁竞争 | `SELECT ... FOR UPDATE` 行级锁 |
| 软删除标记异常 | 定期数据校验任务修正 |
| 批量导入中断 | 分批事务提交,已提交批次保留,未提交回滚 |
### 3.3 脏数据清理规则
| 规则项 | 配置 |
|--------|------|
| 清理周期 | 每日凌晨 3 点 |
| 软删除清理 | 超过 30 天的软删除记录 |
| 孤立素材清理 | 创建超过 7 天且无任何关联记录的素材 |
| 重复素材清理 | 相同 `hash` 值的重复记录 |
| 备份要求 | 清理前备份到冷存储,保留 90 天 |
| 安全间隔 | 先标记 24 小时后无异常再物理删除 |
---
## 四、核心业务流程
### 4.1 资产创建与素材关联
```
用户 → 前端:上传多个素材文件
前端 → OSS分片上传素材
OSS → 前端:返回 oss_key
前端 → 后端:创建资产请求(含素材列表和图层信息)
后端 → materials批量插入素材记录hash 去重)
后端 → assets插入资产记录
后端 → asset_material_relations批量插入关联记录含渲染定位字段
后端 → 前端:返回资产 ID 和素材列表
```
### 4.2 图层顺序调整
```
用户 → 前端:拖拽调整图层顺序
前端 → 后端PUT /api/v1/assets/{id}/materials/layer-order
后端 → 关联表:开启事务 → SELECT FOR UPDATE 锁住该资产所有关联记录
后端 → 关联表:批量更新 layer_order
后端 → 前端:返回更新结果
```
### 4.3 资产删除
```
用户 → 前端:删除资产请求
后端 → assets软删除设置 deleted_at
后端 → asset_material_relations级联软删除所有关联记录
后端 → 前端:返回删除成功
```
---
## 五、API 接口规范
### 5.1 素材上传
```
POST /api/v1/materials/upload
Content-Type: multipart/form-data
入参:
- file: 文件(必填)
- type: 素材类型(可选)
出参:
{ "code": 0, "data": { "material_id": 123, "oss_key": "assets/123.jpg",
"url": "https://oss.example.com/assets/123.jpg", "width": 1080, "height": 1920 } }
```
### 5.2 资产-素材关联
```
POST /api/v1/assets/{asset_id}/materials
Content-Type: application/json
入参:
{ "materials": [
{ "material_id": 123, "material_type": "main", "layer_order": 0,
"pos_x": null, "pos_y": null, "opacity": 1.0, "rotation": 0, "scale_x": 1.0, "scale_y": 1.0 },
{ "material_id": 456, "material_type": "bg", "layer_order": 1 }
] }
出参:
{ "code": 0, "message": "关联成功" }
```
### 5.3 资产素材查询
```
GET /api/v1/assets/{asset_id}/materials
出参:
{ "code": 0, "data": [
{ "relation_id": 1, "material_id": 123, "material_type": "main",
"layer_order": 0, "url": "https://oss.example.com/assets/123.jpg",
"pos_x": null, "pos_y": null, "opacity": 1.0, "rotation": 0,
"scale_x": 1.0, "scale_y": 1.0, "width": 1080, "height": 1920 }
] }
```
### 5.4 图层顺序更新
```
PUT /api/v1/assets/{asset_id}/materials/layer-order
入参:
{ "orders": [ { "relation_id": 1, "layer_order": 0 }, { "relation_id": 2, "layer_order": 1 } ] }
```
### 5.5 权限控制
| 角色 | 权限边界 |
|------|---------|
| 普通用户 | 仅可操作自己创建的素材和资产,校验 `created_by` / `owner_uid` |
| 运营人员 | 可管理所有用户的素材和资产,支持批量操作 |
| 管理员 | 所有权限,含数据清理任务执行权限 |
> MVP 阶段简化为「用户仅可操作自身数据」,运营人员角色后置实现。
---
## 六、Proto 扩展
```protobuf
message Material {
int64 material_id = 1;
string oss_key = 2;
string original_name = 3;
int64 file_size = 4;
string mime_type = 5;
int32 width = 6;
int32 height = 7;
string hash = 8;
int64 created_by = 9;
int64 star_id = 10;
int64 created_at = 11;
}
message AssetMaterialRelation {
int64 relation_id = 1;
int64 asset_id = 2;
int64 material_id = 3;
string material_type = 4;
int32 layer_order = 5;
string material_url_signed = 6;
double pos_x = 7;
double pos_y = 8;
double opacity = 9;
double rotation = 10;
double scale_x = 11;
double scale_y = 12;
}
service AssetService {
rpc UploadMaterial(UploadMaterialRequest) returns (UploadMaterialResponse);
rpc BindAssetMaterials(BindAssetMaterialsRequest) returns (BindAssetMaterialsResponse);
rpc GetAssetMaterials(GetAssetMaterialsRequest) returns (GetAssetMaterialsResponse);
rpc UpdateMaterialLayerOrder(UpdateMaterialLayerOrderRequest) returns (UpdateMaterialLayerOrderResponse);
rpc UnbindAssetMaterial(UnbindAssetMaterialRequest) returns (UnbindAssetMaterialResponse);
}
```
---
## 七、性能优化
### 7.1 缓存策略
| 层级 | 实现 | 配置 |
|------|------|------|
| L1Redis | Hash: `asset:materials:{asset_id}` | TTL 1 小时,写入时主动 INVALIDATE |
| L2go-cache | 热点资产 Top 1000 | TTL 5 分钟,访问频率 > 10次/分钟 提升到热点 |
### 7.2 查询优化
```go
// GORM Preload 方式(推荐)
var asset Asset
db.Preload("Materials", func(db *gorm.DB) *gorm.DB {
return db.Where("asset_material_relations.deleted_at IS NULL").
Order("asset_material_relations.layer_order ASC")
}).First(&asset, assetID)
// 批量查询 Raw SQL高性能场景
rows, _ := db.Raw(`
SELECT a.*, amr.material_type, amr.layer_order, m.oss_key
FROM assets a
LEFT JOIN asset_material_relations amr ON amr.asset_id = a.id AND amr.deleted_at IS NULL
LEFT JOIN materials m ON amr.material_id = m.id AND m.deleted_at IS NULL
WHERE a.id = ? AND a.deleted_at IS NULL
ORDER BY amr.layer_order ASC
`, assetID).Rows()
```
### 7.3 分库分表预案
| 触发条件 | 分表策略 |
|---------|---------|
| `assets` > 1000 万行 | 按 `asset_id` 哈希分 16 表 |
| `asset_material_relations` > 1 亿行 | 同上 |
### 7.4 性能预估
| 场景 | 预估耗时 |
|------|---------|
| 单资产 10 素材查询 | < 5ms |
| 单资产 50 素材查询 | < 15ms |
| 批量 100 资产(平均 5 素材/资产) | < 50ms |
---
## 八、数据迁移与兼容性
### 8.1 迁移策略
| 阶段 | 天数 | 内容 |
|------|------|------|
| 双写 | 第 1-3 天 | 同时写入 `material_url` 和关联表 |
| 灰度切流 | 第 4-5 天 | 1% → 50% → 100% 逐步切读 |
| 观察期 | 第 6-7 天 | 全量走新模型,持续监测 |
| 清理 | 第 14 天 | 停止双写material_url 设为可空 |
### 8.2 迁移脚本核心逻辑
```go
func migrateMaterials() error {
for offset := 0; ; offset += 1000 {
assets := queryAssets(offset, 1000)
if len(assets) == 0 { break }
for _, asset := range assets {
tx := db.Begin()
// 解析 material_url兼容单字符串和 JSON 格式)
materials := parseMaterialURL(asset.MaterialURL)
// 插入 materials 表hash 去重)
materialIDs := batchInsertMaterials(tx, materials)
// 插入关联表
batchInsertRelations(tx, asset.ID, materialIDs, materials)
tx.Commit()
}
}
}
```
### 8.3 灰度观测指标与回滚
| 指标 | 正常阈值 | 回滚触发 |
|------|---------|---------|
| 接口错误率 | < 0.01% | > 1% |
| 查询响应时间 | < 50msP99 | > 200ms |
| 数据一致性 | 100% | < 99.9% |
---
## 九、测试验收标准
### 9.1 测试场景
| 类型 | 场景 |
|------|------|
| 单元测试 | 关联表 CRUD 事务逻辑、乐观锁冲突、外键约束、唯一约束 |
| 集成测试 | 资产创建 → 素材上传 → 关联绑定 → 图层调整 → 资产删除全流程 |
| 压力测试 | 单资产关联 50 个素材QPS=1000验证系统稳定性 |
### 9.2 验收指标
| 指标 | 目标值 |
|------|--------|
| 单资产 10 素材查询 | < 50ms |
| 单资产 50 素材查询 | < 100ms |
| 接口错误率 | < 0.01% |
| 数据一致性通过率 | 100% |
| 支持并发 QPS | ≥ 1000 |
---
## 十、实施计划
```
第 1-2 天DDL 建表 → 测试环境验证
第 3-4 天Go Model + Proto + 网关 DTO + Converter
第 5-6 天Service CRUD + 事务 + 缓存
第 7 天: 前端适配 + 数据迁移脚本
第 8-9 天:灰度发布(双写 → 切流 → 观察)
第 10 天: 上线确认 + 回滚预案待命
```
| 合计 | 10 个工作日 |
---
## 十一、关联文档
| 文档 | 说明 |
|------|------|
| [Asset 数据模型](file:///e:/develop/code/topfans/backend/pkg/models/asset.go) | 现有 assets/mint_orders 表结构 |
| [Proto 定义](file:///e:/develop/code/topfans/backend/proto/asset.proto) | 现有 RPC 消息定义 |
| [资产控制器](file:///e:/develop/code/topfans/backend/gateway/controller/asset_controller.go) | 现有网关层处理逻辑 |
| [Redis 配置](file:///e:/develop/code/topfans/backend/pkg/database/redis.go) | 现有 Redis 客户端 |
| [铸爱提交流程](file:///e:/develop/code/topfans/frontend/utils/craftMintSubmit.js) | OSS 上传 + 创建订单 |
| [铸造路由管理](file:///e:/develop/code/topfans/frontend/utils/castloveGenerationFlow.js) | Storage Key 管理 + 页面跳转 |

View File

@ -0,0 +1,266 @@
<template>
<view class="holo-container">
<view class="holo-frame" :style="frameStyle">
<!-- WebGL 渲染层 Canvas 缓冲 = CSS × DPRMipmap + MSAA -->
<canvas
v-show="webglReady && !hasError"
ref="webglCanvas"
class="holo-canvas"
:style="canvasStyle"
@touchstart.stop="onTouchStart"
@touchmove.stop.prevent="onTouchMove"
@touchend.stop="onTouchEnd"
@touchcancel.stop="onTouchEnd"
/>
<!-- CSS 降级层WebGL 不可用时自动启用 -->
<view v-if="!webglReady || hasError" class="holo-fallback" :style="fallbackStyle">
<view class="holo-fallback-card" :style="fallbackCardStyle">
<image
v-if="baseImageSrc"
class="holo-fallback-img"
:src="baseImageSrc"
mode="aspectFill"
/>
<view class="holo-fallback-shimmer" />
<view class="holo-fallback-rim" />
</view>
</view>
<view v-if="showHint && hintText" class="holo-hint" :class="{ 'holo-hint--hidden': !showHint }">
<text class="holo-hint-icon"></text>
<text class="holo-hint-text">{{ hintText }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { HolographicEngine } from '@/utils/webgl/holographic-engine.js'
import { loadTextureImage } from '@/utils/laser-card/laserPreviewWebgl.js'
const props = defineProps({
baseImageSrc: { type: String, default: '' },
aspectRatio: { type: Number, default: 3 / 4 },
maxWidth: { type: Number, default: 420 },
cornerRadius: { type: Number, default: 24 },
effectIntensity: { type: Number, default: 0.85 },
dispersionStrength: { type: Number, default: 1.0 },
diffractionScale: { type: Number, default: 0.7 },
highlightSpeed: { type: Number, default: 0.8 },
highlightWidth: { type: Number, default: 1.0 },
fresnelPower: { type: Number, default: 3.5 },
noiseScale: { type: Number, default: 1.0 },
noiseOctaves: { type: Number, default: 6 },
safeZoneRadius: { type: Number, default: 0.35 },
safeZoneSoftness: { type: Number, default: 0.15 },
viewX: { type: Number, default: 0 },
viewY: { type: Number, default: 0 },
viewZ: { type: Number, default: 0 },
hintText: { type: String, default: '移动光标或倾斜设备' },
showHint: { type: Boolean, default: true },
maxDPR: { type: Number, default: 2 },
skipBuiltInTouch: { type: Boolean, default: false },
})
const emit = defineEmits(['simulate', 'ready', 'error', 'fpsUpdate'])
const webglCanvas = ref(null)
const webglReady = ref(false)
const hasError = ref(false)
let engine = null
let resizeObserver = null
let fpsInterval = null
function resolveSize() {
const vw = typeof window !== 'undefined' ? window.innerWidth : 375
const w = Math.min(props.maxWidth, vw * 0.92)
const h = w / props.aspectRatio
return { w, h }
}
const frameStyle = computed(() => {
const { w, h } = resolveSize()
return { width: `${w}px`, height: `${h}px` }
})
const canvasStyle = computed(() => {
const { w, h } = resolveSize()
return { width: `${w}px`, height: `${h}px` }
})
const fallbackStyle = computed(() => {
const { w, h } = resolveSize()
return { width: `${w}px`, height: `${h}px` }
})
const fallbackCardStyle = computed(() => {
const vx = props.viewX || 0
const vy = props.viewY || 0
return {
borderRadius: `${props.cornerRadius}px`,
transform: `perspective(1000px) rotateY(${vx * 15}deg) rotateX(${-vy * 8}deg)`,
}
})
function dispatchTouch(clientX, clientY) {
if (props.skipBuiltInTouch) return
const canvas = webglCanvas.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
if (!rect.width || !rect.height) return
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
emit('simulate',
Math.max(-1, Math.min(1, (clientX - cx) / (rect.width / 2) * 1.1)),
Math.max(-1, Math.min(1, (clientY - cy) / (rect.height / 2) * 1.1))
)
}
function getT(e) {
if (e.touches && e.touches[0]) return e.touches[0]
if (e.changedTouches && e.changedTouches[0]) return e.changedTouches[0]
return null
}
function onTouchStart(e) { onTouchMove(e) }
function onTouchMove(e) {
if (props.skipBuiltInTouch) return
const t = getT(e)
if (t) dispatchTouch(t.clientX, t.clientY)
}
function onTouchEnd() {
if (props.skipBuiltInTouch) return
emit('simulate', 0, 0)
}
function syncEngineParams() {
if (!engine) return
engine.setEffectParams({
effectIntensity: props.effectIntensity,
dispersionStrength: props.dispersionStrength,
diffractionScale: props.diffractionScale,
highlightSpeed: props.highlightSpeed,
highlightWidth: props.highlightWidth,
fresnelPower: props.fresnelPower,
noiseScale: props.noiseScale,
noiseOctaves: Math.round(props.noiseOctaves),
cardCornerRadius: props.cornerRadius,
safeZoneRadius: props.safeZoneRadius,
safeZoneSoftness: props.safeZoneSoftness,
})
engine.setViewAngle(props.viewX, props.viewY, props.viewZ)
}
watch(() => [
props.effectIntensity, props.dispersionStrength, props.diffractionScale,
props.highlightSpeed, props.highlightWidth, props.fresnelPower,
props.noiseScale, props.noiseOctaves, props.cornerRadius,
props.safeZoneRadius, props.safeZoneSoftness,
props.viewX, props.viewY, props.viewZ,
], syncEngineParams)
watch(() => props.baseImageSrc, async (src) => {
if (!engine || !src) return
try {
const img = await loadTextureImage(src)
engine.uploadBaseImage(img)
} catch (e) {
console.warn('[HolographicCard] load baseImage failed:', e)
}
})
function initWebGL() {
if (!HolographicEngine.isSupported()) {
hasError.value = true
emit('error', new Error('WebGL not supported'))
return
}
const canvas = webglCanvas.value
if (!canvas) return
try {
const { w, h } = resolveSize()
engine = new HolographicEngine(canvas, {
alpha: true,
antialias: true,
useFBO: false,
})
engine.setDPR(Math.min(window.devicePixelRatio || 1, props.maxDPR))
if (!engine.init()) {
hasError.value = true
emit('error', new Error('WebGL init failed'))
return
}
syncEngineParams()
engine.start()
webglReady.value = true
emit('ready')
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver((entries) => {
for (const e of entries) {
const { width, height } = e.contentRect
if (engine && width > 0 && height > 0) engine.resize(width, height)
}
})
resizeObserver.observe(canvas.parentElement || canvas)
}
fpsInterval = setInterval(() => {
if (engine) emit('fpsUpdate', engine.getFPS())
}, 1000)
if (props.baseImageSrc) {
loadTextureImage(props.baseImageSrc)
.then(img => engine.uploadBaseImage(img))
.catch(e => console.warn('[HolographicCard] init load:', e))
}
} catch (e) {
console.error('[HolographicCard] init error:', e)
hasError.value = true
emit('error', e)
}
}
onMounted(() => { nextTick(initWebGL) })
onUnmounted(() => {
if (fpsInterval) { clearInterval(fpsInterval); fpsInterval = null }
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null }
if (engine) { engine.destroy(); engine = null }
})
</script>
<style scoped>
.holo-container { width: 100%; display: flex; align-items: center; justify-content: center; }
.holo-frame { position: relative; max-width: 100%; }
.holo-canvas { display: block; border-radius: inherit; }
/* ---- CSS 降级 ---- */
.holo-fallback { position: relative; display: flex; align-items: center; justify-content: center; perspective: 1000px; }
.holo-fallback-card {
width: 100%; height: 100%; overflow: hidden; position: relative;
background-color: #060e20; box-shadow: 0 20px 50px rgba(0,0,0,0.55);
border: 1px solid rgba(255,255,255,0.18);
will-change: transform; transform-style: preserve-3d;
}
.holo-fallback-img { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
.holo-fallback-shimmer {
position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(135deg, rgba(221,183,255,0.08) 0%, rgba(100,200,255,0.15) 25%, transparent 50%, rgba(255,180,220,0.12) 75%, rgba(76,215,246,0.08) 100%);
}
.holo-fallback-rim {
position: absolute; inset: 0;
border-top: 1px solid rgba(221,183,255,0.42);
box-shadow: inset 0 0 40px rgba(255,255,255,0.05);
pointer-events: none;
}
/* ---- 提示 ---- */
.holo-hint {
position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 6px;
padding: 8px 16px; background: rgba(0,0,0,0.55); backdrop-filter: blur(12px);
border-radius: 20px; transition: opacity 0.4s ease; pointer-events: none;
}
.holo-hint--hidden { opacity: 0; }
.holo-hint-icon { font-size: 16px; color: rgba(255,255,255,0.8); }
.holo-hint-text { font-size: 12px; color: rgba(255,255,255,0.75); }
</style>

View File

@ -0,0 +1,142 @@
/**
* 全息镭射卡预览组合式函数
*
* 集成 WebGL HolographicEngine + 陀螺仪/触摸交互
* 与现有 useLenticularPreview / useLenticularStudioTilt 架构保持一致
*/
import { ref, reactive, onMounted, onUnmounted } from 'vue'
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)) }
function lerp(a, b, t) { return a + (b - a) * t }
export function useHolographicPreview() {
const physics = reactive({
tiltSensitivity: 72,
transitionSmoothness: 66,
angleStability: 88,
sensorDeadzoneStrength: 1,
gyroSimEnabled: true,
})
const viewAngle = reactive({ x: 0, y: 0, z: 0 })
const gyroSource = ref('simulation')
const isWebGLReady = ref(false)
const hasWebGLError = ref(false)
const fps = ref(0)
let accelHandler = null
let accelSmoothed = 0
let accelBaselineReady = false
let accelBaseX = 0
let accelBaseY = 0
function simulate(x, y) {
viewAngle.x = clamp(x, -1, 1)
viewAngle.y = clamp(y != null ? y : 0, -1, 1)
viewAngle.z = Math.sqrt(viewAngle.x * viewAngle.x + viewAngle.y * viewAngle.y) * 0.5
}
function relax(factor = 0.85) {
viewAngle.x *= factor
viewAngle.y *= factor
viewAngle.z *= factor
}
function startGyro() {
gyroSource.value = 'simulation'
if (typeof DeviceOrientationEvent === 'undefined') return
if (typeof DeviceOrientationEvent.requestPermission === 'function') {
gyroSource.value = 'deviceorientation-requesting'
DeviceOrientationEvent.requestPermission()
.then(state => { if (state === 'granted') startDeviceOrientation() })
.catch(() => { gyroSource.value = 'simulation' })
} else {
startDeviceOrientation()
}
}
function startDeviceOrientation() {
gyroSource.value = 'deviceorientation'
const handler = (e) => {
if (e.gamma == null || e.beta == null) return
const gamma = e.gamma || 0
const beta = e.beta || 0
if (!accelBaselineReady) {
accelBaseX = lerp(accelBaseX, gamma, 0.08)
accelBaseY = lerp(accelBaseY, beta, 0.08)
if (Math.abs(accelBaseX - gamma) < 0.3 && Math.abs(accelBaseY - beta) < 0.3) {
accelBaselineReady = true
}
return
}
const stab = clamp(physics.angleStability / 100, 0, 1)
const sens = physics.tiltSensitivity / 100
const k = 0.02 + (1 - stab) * 0.08
const dx = (gamma - accelBaseX) / 45 * sens
const dy = (beta - accelBaseY) / 45 * sens
accelSmoothed = lerp(accelSmoothed, dx, k)
const dead = physics.sensorDeadzoneStrength || 1
const db = 0.016 * dead
simulate(
Math.abs(accelSmoothed) < db ? 0 : clamp(accelSmoothed, -1, 1),
clamp(dy, -1, 1)
)
}
window.addEventListener('deviceorientation', handler, true)
accelHandler = handler
}
function stopGyro() {
if (accelHandler) {
window.removeEventListener('deviceorientation', accelHandler, true)
accelHandler = null
}
gyroSource.value = 'simulation'
accelBaselineReady = false
}
function onWebGLReady() { isWebGLReady.value = true; hasWebGLError.value = false }
function onWebGLError() { hasWebGLError.value = true; isWebGLReady.value = false }
function onFPSUpdate(v) { fps.value = v }
onMounted(() => {
if (physics.gyroSimEnabled) setTimeout(startGyro, 600)
})
onUnmounted(() => { stopGyro() })
return {
physics, viewAngle, gyroSource,
isWebGLReady, hasWebGLError, fps,
simulate, relax, startGyro, stopGyro,
onWebGLReady, onWebGLError, onFPSUpdate,
}
}
export function detectPerformanceTier() {
const mem = navigator.deviceMemory || 4
const cores = navigator.hardwareConcurrency || 4
if (mem <= 2 || cores <= 2) return 'low'
if (mem <= 4 || cores <= 4) return 'mid'
return 'high'
}
export const HOLO_PERFORMANCE_PRESETS = {
high: {
effectIntensity: 0.85, dispersionStrength: 1.0, diffractionScale: 0.7,
highlightSpeed: 0.8, highlightWidth: 1.0, fresnelPower: 3.5,
noiseScale: 1.0, noiseOctaves: 6,
safeZoneRadius: 0.35, safeZoneSoftness: 0.15,
},
mid: {
effectIntensity: 0.75, dispersionStrength: 0.8, diffractionScale: 0.55,
highlightSpeed: 0.7, highlightWidth: 1.1, fresnelPower: 3.0,
noiseScale: 0.8, noiseOctaves: 4,
safeZoneRadius: 0.35, safeZoneSoftness: 0.15,
},
low: {
effectIntensity: 0.6, dispersionStrength: 0.5, diffractionScale: 0.35,
highlightSpeed: 0.5, highlightWidth: 1.3, fresnelPower: 2.5,
noiseScale: 0.55, noiseOctaves: 3,
safeZoneRadius: 0.38, safeZoneSoftness: 0.18,
},
}

View File

@ -133,12 +133,40 @@
<view class="content-wrapper">
<!-- 藏品卡片区域 -->
<view class="card-section">
<view class="card-wrapper">
<view class="card-wrapper" :class="{ 'card-wrapper--lenticular': isLenticularAsset }">
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFit">
</image>
<image class="card-image" :src="coverUrl" mode="aspectFill"></image>
<!-- <view class="card-badge"></view> -->
<view v-if="isLenticularAsset" class="detail-lenticular-slot">
<LenticularCard
class="detail-lenticular-card"
:layers="lenticularLayers"
:transforms="layerTransforms"
:gyro-source="gyroSourceLabel"
:skip-built-in-touch="true"
tilt-hint-text="倾斜手机查看光栅效果"
:shimmer-mid-opacity="0.16"
@simulate="simulate"
/>
</view>
<image v-else class="card-image" :src="coverUrl" mode="aspectFill"></image>
<!-- 贴纸叠加层 -->
<image
v-for="sticker in activeStickers"
:key="sticker.id"
class="card-sticker"
:src="sticker.src"
mode="aspectFit"
:style="getStickerStyle(sticker)"
/>
</view>
<!-- 贴纸合成导出隐藏画布 -->
<canvas
v-if="activeStickers.length"
canvas-id="stickerCompositCanvas"
class="export-canvas"
:style="{ position: 'fixed', left: '-9999px', top: '-9999px', width: '450px', height: '600px' }"
/>
<!-- 点赞 + 收益 + 倒计时行 -->
<view class="card-meta-row">
@ -293,9 +321,11 @@ import LenticularCard from '@/components/lenticular/LenticularCard.vue';
import { useLenticularCraftTiltPreview } from '@/composables/useLenticularCraftTiltPreview.js';
import {
buildLenticularLayersTwo,
buildLenticularLayers,
LENTICULAR_STUDIO_STORAGE_KEY,
CRAFT_LENTICULAR_CN,
CRAFT_LASER_CARD_CN,
CRAFT_TAG_LENTICULAR,
} from '@/utils/castloveMintForm.js';
import {
CASTLOVE_FORM_KEY,
@ -304,6 +334,7 @@ import {
STUDIO_LASER,
} from '@/utils/castloveGenerationFlow.js';
import { submitCraftMintFromPath } from '@/utils/craftMintSubmit.js';
import { composeStickers, relationsToStickers } from '@/utils/sticker-compositor.js';
//
const assetIdParam = ref('');
const orderIdParam = ref('');
@ -324,6 +355,15 @@ const {
stopTiltPreview,
} = useLenticularCraftTiltPreview(lenticularLayers);
const isLenticularAsset = computed(() => {
if (craftConfirmMode.value) return false
const tags = assetData.value?.tags
if (Array.isArray(tags) && tags.includes(CRAFT_TAG_LENTICULAR)) return true
return false
})
const isLenticularDetail = ref(false)
const isCraftLenticular = computed(() => studioKindParam.value === STUDIO_LENTICULAR);
const craftCategoryLabel = computed(() => {
if (isCraftLenticular.value) return CRAFT_LENTICULAR_CN;
@ -362,6 +402,55 @@ const likedUsers = ref([
{ avatar: '/static/sucai/image-06.png', ellipseX: -16, ellipseY: 32 ,size:1.15 }
]);
// ---- ----
const activeStickers = ref([]);
const compositingStickers = ref(false);
function getStickerStyle(sticker) {
const ox = (sticker.pos_x != null ? sticker.pos_x : 0.5) * 100
const oy = (sticker.pos_y != null ? sticker.pos_y : 0.5) * 100
const rot = sticker.rotation != null ? sticker.rotation : 0
const scX = sticker.scale_x != null ? sticker.scale_x : 1
const scY = sticker.scale_y != null ? sticker.scale_y : 1
const op = sticker.opacity != null ? sticker.opacity : 1
return {
left: `${ox}%`,
top: `${oy}%`,
transform: `translate(-50%, -50%) rotate(${rot}deg) scale(${scX}, ${scY})`,
opacity: op,
}
}
async function loadStickersForAsset(materialRelations) {
if (!materialRelations || !materialRelations.length) {
activeStickers.value = []
return
}
const stickers = relationsToStickers(materialRelations)
const withIds = stickers.map((s, i) => ({ ...s, id: `sticker_${i}` }))
activeStickers.value = withIds
}
async function exportCompositeImage() {
if (!coverUrl.value || !activeStickers.value.length) return null
compositingStickers.value = true
try {
const result = await composeStickers({
baseImageSrc: coverUrl.value,
stickers: activeStickers.value,
exportW: 450,
exportH: 600,
canvasId: 'stickerCompositCanvas',
})
return result
} catch (e) {
console.error('[asset-detail] sticker compose failed:', e)
return null
} finally {
compositingStickers.value = false
}
}
//
const showLikeUsersModal = ref(false);
const likeUsersActiveTab = ref(0);
@ -489,6 +578,11 @@ const loadData = async () => {
isLiked.value = res.data.asset.is_liked || res.data.is_liked || false;
likeCount.value = asset.like_count || 0;
//
if (asset.material_relations || asset.materials) {
loadStickersForAsset(asset.material_relations || asset.materials)
}
if (asset.remain_time > 0) {
remainSeconds.value = asset.remain_time;
startCountdown();
@ -499,6 +593,21 @@ const loadData = async () => {
getAssetCoverRealUrl(asset.cover_url).then(url => {
console.log('封面图片加载成功:', url);
coverUrl.value = url;
if (isLenticularAsset.value) {
const materialUrl = asset.material_url || url
let subjectUrl = url
let bgUrl = ''
try {
const parsed = JSON.parse(materialUrl)
if (parsed.main) subjectUrl = parsed.main
if (parsed.bg) bgUrl = parsed.bg
} catch (e) { /* 非 JSON 格式,按单 URL 处理 */ }
lenticularLayers.value = bgUrl
? buildLenticularLayersTwo(bgUrl, subjectUrl)
: buildLenticularLayers(subjectUrl)
isLenticularDetail.value = true
scheduleTiltStart()
}
}).catch(err => {
console.error('加载封面图片失败:', err);
coverUrl.value = ''; //
@ -595,10 +704,13 @@ const handleCraftMint = async () => {
uni.showToast({ title: '缺少作品图', icon: 'none' });
return;
}
const bgImagePath = isCraftLenticular.value
? lenticularLayers.value.find((l) => l.id === 'base')?.src || ''
: undefined;
craftMinting.value = true;
uni.showLoading({ title: '铸造中…', mask: true });
try {
await submitCraftMintFromPath({ imagePath, formData: craftFormData.value });
await submitCraftMintFromPath({ imagePath, bgImagePath, formData: craftFormData.value });
uni.hideLoading();
uni.navigateTo({ url: '/pages/castlove/success' });
} catch (e) {
@ -636,6 +748,10 @@ onShow(() => {
}
return;
}
if (isLenticularDetail.value && lenticularLayers.value.length) {
scheduleTiltStart()
return
}
const currentKey = `${assetIdParam.value}_${orderIdParam.value}`;
const hasTarget = !!(assetIdParam.value || orderIdParam.value);
if (!hasTarget) {
@ -653,6 +769,9 @@ onHide(() => {
if (craftConfirmMode.value && isCraftLenticular.value) {
stopTiltPreview();
}
if (isLenticularDetail.value) {
stopTiltPreview()
}
});
onUnmounted(() => {
@ -868,6 +987,26 @@ onUnmounted(() => {
margin-bottom: 32rpx;
}
.card-wrapper--lenticular {
width: 520rpx;
height: 680rpx;
}
.detail-lenticular-slot {
position: absolute;
left: 50%;
top: 50%;
width: 78%;
height: 82%;
transform: translate(-50%, -50%) rotate(-10deg);
z-index: 2;
}
.detail-lenticular-card {
width: 100%;
height: 100%;
}
.card-image {
width: 88%;
height: 96%;
@ -892,15 +1031,20 @@ onUnmounted(() => {
}
.card-badge {
.card-sticker {
position: absolute;
top: 40rpx;
left: 40rpx;
width: 60rpx;
height: 60rpx;
background: red;
border-radius: 20rpx;
z-index: 4;
z-index: 5;
width: 72rpx;
height: 72rpx;
pointer-events: none;
transform-origin: center center;
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.35));
}
.export-canvas {
position: fixed;
left: -9999px;
top: -9999px;
}
.card-meta-row {

View File

@ -33,9 +33,9 @@ function uploadFileToOss(tempFilePath, ossData) {
/**
* 铸爱确认页上传选中图并创建铸造订单
* @param {{ imagePath: string, formData: object }} opts
* @param {{ imagePath: string, bgImagePath?: string, formData: object }} opts
*/
export async function submitCraftMintFromPath({ imagePath, formData }) {
export async function submitCraftMintFromPath({ imagePath, bgImagePath, formData }) {
const path = String(imagePath || '').trim()
if (!path) {
throw new Error('缺少作品图片')
@ -86,10 +86,26 @@ export async function submitCraftMintFromPath({ imagePath, formData }) {
uploadedImageBase64: formData.imageBase64 || '',
})
const isLenticular = Array.isArray(snap.tags) && snap.tags.includes('craft:lenticular')
let bgUrl = ''
if (isLenticular && bgImagePath) {
const bgPath = String(bgImagePath || '').trim()
if (bgPath && !bgPath.startsWith('http')) {
bgUrl = await uploadFileToOss(bgPath, ossData)
} else if (bgPath) {
bgUrl = bgPath
}
}
const materialUrl = isLenticular && bgUrl
? JSON.stringify({ main: imageUrl, bg: bgUrl })
: imageUrl
const orderData = {
order_id: orderId,
name: snap.name,
material_url: imageUrl,
material_url: materialUrl,
description: snap.description || '',
grade: snap.grade ?? 0,
tags: Array.isArray(snap.tags) ? snap.tags : [],
@ -118,6 +134,7 @@ export async function submitCraftMintFromPath({ imagePath, formData }) {
asset_id: assetId,
info: snap.info,
event: snap.info,
...(isLenticular && bgUrl ? { bg_image: bgUrl } : {}),
}
uni.setStorageSync('temp_nft_data', JSON.stringify(nftData))
uni.removeStorageSync('castlove_form_data')

View File

@ -0,0 +1,330 @@
/**
* 贴纸合成引擎Canvas 2D
*
* 将贴纸/装饰素材按指定位置旋转缩放透明度混合模式
* 叠加到目标人物图片上输出高清合成图
*
* 数据库对齐asset_material_relations 表的字段
* material_type, pos_x, pos_y, opacity, rotation,
* scale_x, scale_y, layer_order
*/
const DEFAULT_EXPORT_W = 450
const DEFAULT_EXPORT_H = 600
const CORNER_RADIUS = 32
// ---- 图片工具 ----
function getImageInfo(src) {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src,
success: resolve,
fail: (e) => reject(e || new Error('读取图片失败')),
})
})
}
function computeCover(iw, ih, cw, ch) {
const w = Number(iw) || 1
const h = Number(ih) || 1
const scale = Math.max(cw / w, ch / h)
const dw = w * scale
const dh = h * scale
return { dx: (cw - dw) / 2, dy: (ch - dh) / 2, dw, dh }
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v))
}
// ---- Canvas 兼容工具 ----
function canvasDraw(ctx) {
return new Promise((resolve) => {
ctx.draw(false, () => setTimeout(resolve, 80))
})
}
function setBlend(ctx, mode) {
if (typeof ctx.setGlobalCompositeOperation === 'function') {
ctx.setGlobalCompositeOperation(mode)
}
}
function clipRoundRect(ctx, cw, ch, r) {
const rr = r || Math.min(32, cw * 0.065, ch * 0.048)
ctx.beginPath()
ctx.moveTo(rr, 0)
ctx.lineTo(cw - rr, 0)
ctx.arc(cw - rr, rr, rr, -Math.PI / 2, 0, false)
ctx.lineTo(cw, ch - rr)
ctx.arc(cw - rr, ch - rr, rr, 0, Math.PI / 2, false)
ctx.lineTo(rr, ch)
ctx.arc(rr, ch - rr, rr, Math.PI / 2, Math.PI, false)
ctx.lineTo(0, rr)
ctx.arc(rr, rr, rr, Math.PI, Math.PI * 1.5, false)
ctx.closePath()
ctx.clip()
}
function canvasToTemp(canvasId, w, h) {
return new Promise((resolve, reject) => {
uni.canvasToTempFilePath({
canvasId,
width: w,
height: h,
destWidth: w * 2,
destHeight: h * 2,
fileType: 'jpg',
quality: 0.96,
success: (res) => resolve(res.tempFilePath),
fail: (e) => reject(e || new Error('导出失败')),
})
})
}
// ---- 阴影绘制(让贴纸看起来真实贴合) ----
function drawStickerShadow(ctx, stickerData, cw, ch) {
const { pos_x = 0.5, pos_y = 0.5, scale_x = 1, scale_y = 1, rotation = 0, stickerLayout } = stickerData
if (!stickerLayout) return
const cx = pos_x * cw
const cy = pos_y * ch
const sw = stickerLayout.dw * scale_x
const sh = stickerLayout.dh * scale_y
const approxR = Math.max(sw, sh) * 0.55
ctx.save()
ctx.translate(cx + 2, cy + 2)
ctx.rotate((rotation * Math.PI) / 180)
const shadowGrad = ctx.createRadialGradient
? ctx.createRadialGradient(0, 0, approxR * 0.2, 0, 0, approxR)
: ctx.createCircularGradient
? ctx.createCircularGradient(0, 0, approxR)
: ctx.createLinearGradient(-approxR, -approxR, approxR, approxR)
if (shadowGrad.addColorStop) {
shadowGrad.addColorStop(0, 'rgba(0,0,0,0.25)')
shadowGrad.addColorStop(0.5, 'rgba(0,0,0,0.08)')
shadowGrad.addColorStop(1, 'rgba(0,0,0,0)')
}
ctx.setGlobalAlpha(0.6)
ctx.setFillStyle(shadowGrad || 'rgba(0,0,0,0.12)')
ctx.fillRect(-approxR, -approxR, approxR * 2, approxR * 2)
ctx.restore()
ctx.setGlobalAlpha(1)
}
// ---- 贴纸绘制 ----
function drawSticker(ctx, stickerData, cw, ch) {
const {
pos_x = 0.5,
pos_y = 0.5,
scale_x = 1,
scale_y = 1,
rotation = 0,
opacity = 1,
blendMode = 'source-over',
stickerLayout,
} = stickerData
if (!stickerLayout || !stickerLayout.path) return
const cx = pos_x * cw
const cy = pos_y * ch
const sw = stickerLayout.dw * scale_x
const sh = stickerLayout.dh * scale_y
ctx.save()
ctx.translate(cx, cy)
ctx.rotate((rotation * Math.PI) / 180)
ctx.setGlobalAlpha(clamp(opacity, 0, 1))
setBlend(ctx, blendMode)
ctx.drawImage(stickerLayout.path, -sw / 2, -sh / 2, sw, sh)
ctx.restore()
ctx.setGlobalAlpha(1)
setBlend(ctx, 'source-over')
}
// ---- 边缘微光(让贴纸融入场景) ----
function drawStickerEdgeGlow(ctx, stickerData, cw, ch) {
const {
pos_x = 0.5,
pos_y = 0.5,
scale_x = 1,
scale_y = 1,
rotation = 0,
opacity = 1,
stickerLayout,
} = stickerData
if (opacity < 0.3 || !stickerLayout) return
const cx = pos_x * cw
const cy = pos_y * ch
const sw = stickerLayout.dw * scale_x
const sh = stickerLayout.dh * scale_y
const approxR = Math.max(sw, sh) * 0.55
ctx.save()
ctx.translate(cx, cy)
ctx.rotate((rotation * Math.PI) / 180)
const glowGrad = ctx.createRadialGradient
? ctx.createRadialGradient(0, 0, approxR * 0.35, 0, 0, approxR)
: ctx.createLinearGradient(-approxR, -approxR, approxR, approxR)
if (glowGrad.addColorStop) {
glowGrad.addColorStop(0, 'rgba(255,255,255,0)')
glowGrad.addColorStop(0.7, 'rgba(255,255,255,0.06)')
glowGrad.addColorStop(1, 'rgba(255,255,255,0)')
}
setBlend(ctx, 'soft-light')
ctx.setGlobalAlpha(clamp(opacity * 0.35, 0, 0.35))
ctx.setFillStyle(glowGrad || 'rgba(255,255,255,0.04)')
ctx.fillRect(-approxR, -approxR, approxR * 2, approxR * 2)
ctx.restore()
ctx.setGlobalAlpha(1)
setBlend(ctx, 'source-over')
}
// ============ 公开 API ============
/**
* 预计算贴纸的 cover 布局
* @param {string} stickerSrc 贴纸图片路径
* @param {number} cw 画布宽度
* @param {number} ch 画布高度
* @param {number} [targetFraction=0.22] 贴纸占画布的比例
*/
export async function computeStickerLayout(stickerSrc, cw, ch, targetFraction = 0.22) {
const info = await getImageInfo(stickerSrc)
const targetW = cw * targetFraction
const targetH = ch * targetFraction
const cover = computeCover(info.width, info.height, targetW, targetH)
return { ...cover, path: info.path }
}
/**
* 在画布上绘制完整贴纸合成层
*
* @param {CanvasContext} ctx uni-app Canvas 上下文
* @param {number} cw 画布宽度
* @param {number} ch 画布高度
* @param {Array} stickers 贴纸数组每项包含
* { pos_x, pos_y, scale_x, scale_y, rotation, opacity, blendMode, stickerLayout }
* @param {boolean} [drawShadow=true] 是否绘制投影
* @param {boolean} [drawGlow=true] 是否绘制边缘柔光
*/
export function drawStickers(ctx, cw, ch, stickers, { drawShadow = true, drawGlow = true } = {}) {
if (!stickers || !stickers.length) return
const sorted = [...stickers].sort((a, b) => (a.layer_order || 0) - (b.layer_order || 0))
for (const sticker of sorted) {
if (!sticker.stickerLayout || !sticker.stickerLayout.path) continue
if (drawShadow) drawStickerShadow(ctx, sticker, cw, ch)
drawSticker(ctx, sticker, cw, ch)
if (drawGlow) drawStickerEdgeGlow(ctx, sticker, cw, ch)
}
}
/**
* 完整贴纸合成人物底图 贴纸叠加 导出
*
* @param {object} opts
* @param {string} opts.baseImageSrc 人物底图路径
* @param {Array} opts.stickers 贴纸配置数组
* @param {number} [opts.exportW=450] 导出宽度
* @param {number} [opts.exportH=600] 导出高度
* @param {string} [opts.canvasId] uni-app canvas-id
* @param {number} [opts.cornerRadius] 圆角半径
* @returns {Promise<string>} 导出后的临时文件路径
*/
export async function composeStickers(opts) {
const {
baseImageSrc,
stickers = [],
exportW = DEFAULT_EXPORT_W,
exportH = DEFAULT_EXPORT_H,
canvasId = 'stickerCompositCanvas',
cornerRadius = CORNER_RADIUS,
} = opts
const ctx = uni.createCanvasContext(canvasId)
// 1. 预加载贴纸布局
const stickerLayouts = await Promise.all(
stickers.map(async (s) => {
if (!s.src) return { ...s, stickerLayout: null }
try {
const layout = await computeStickerLayout(s.src, exportW, exportH, s.sizeFraction || 0.22)
return { ...s, stickerLayout: layout }
} catch (e) {
console.warn('[sticker-compositor] load sticker failed:', s.src, e)
return { ...s, stickerLayout: null }
}
})
)
// 2. 绘制底色
ctx.setFillStyle('#0a0a0a')
ctx.fillRect(0, 0, exportW, exportH)
// 3. 圆角裁剪
ctx.save()
clipRoundRect(ctx, exportW, exportH, cornerRadius)
// 4. 绘制人物底图cover 填满)
if (baseImageSrc) {
const baseInfo = await getImageInfo(baseImageSrc)
const cover = computeCover(baseInfo.width, baseInfo.height, exportW, exportH)
ctx.drawImage(baseInfo.path, cover.dx, cover.dy, cover.dw, cover.dh)
}
await canvasDraw(ctx)
// 5. 逐层叠加贴纸
drawStickers(ctx, exportW, exportH, stickerLayouts, { drawShadow: true, drawGlow: true })
ctx.restore()
await canvasDraw(ctx)
// 6. 导出高清图
const tempPath = await canvasToTemp(canvasId, exportW, exportH)
return tempPath
}
/**
* asset_material_relations 记录转换为贴纸配置
*
* @param {Array} relations asset_material_relations 行数组
* @returns {Array} 贴纸配置数组
*/
export function relationsToStickers(relations) {
if (!relations || !relations.length) return []
return relations
.filter((r) => r.material_type === 'sticker' || r.material_type === 'patch')
.map((r) => ({
src: r.oss_key || r.material_url || '',
pos_x: r.pos_x != null ? Number(r.pos_x) : 0.5,
pos_y: r.pos_y != null ? Number(r.pos_y) : 0.5,
opacity: r.opacity != null ? Number(r.opacity) : 1,
rotation: r.rotation != null ? Number(r.rotation) : 0,
scale_x: r.scale_x != null ? Number(r.scale_x) : 1,
scale_y: r.scale_y != null ? Number(r.scale_y) : 1,
layer_order: r.layer_order != null ? Number(r.layer_order) : 0,
blendMode: r.blend_mode || 'source-over',
}))
}

View File

@ -0,0 +1,421 @@
/**
* 全息镭射卡 WebGL 渲染引擎
*
* 反模糊关键设计
* 1. Canvas 分辨率 = CSS尺寸 × DPR上限2保证像素密度
* 2. 基础图像纹理启用 MipmapGL_LINEAR_MIPMAP_LINEAR 采样
* 3. WebGL context 开启 MSAA 抗锯齿
* 4. FBO 离屏渲染时分辨率与 Canvas 完全一致
* 5. 安全区域着色器内 baseColor 直通输出
*/
import { HOLO_VERT_SRC, HOLO_FRAG_SRC, generateSpectrumRampData } from './holographic-shaders.js'
const DEFAULT_CONFIG = {
alpha: true,
antialias: true,
premultipliedAlpha: false,
preserveDrawingBuffer: false,
powerPreference: 'high-performance',
failIfMajorPerformanceCaveat: false,
}
const DEFAULT_EFFECT_PARAMS = {
effectIntensity: 0.85,
dispersionStrength: 1.0,
diffractionScale: 0.7,
highlightSpeed: 0.8,
highlightWidth: 1.0,
fresnelPower: 3.5,
noiseScale: 1.0,
noiseOctaves: 6,
cardCornerRadius: 24,
safeZoneRadius: 0.35,
safeZoneSoftness: 0.15,
}
export class HolographicEngine {
constructor(canvas, opts = {}) {
this.canvas = canvas
this.gl = null
this.program = null
this.uniforms = {}
this.attribs = {}
this._initialized = false
this._destroyed = false
this._rafId = null
this._lastFrameTime = 0
this._dpr = 1
this._cssW = 0
this._cssH = 0
this._config = { ...DEFAULT_CONFIG, ...opts }
this._effectParams = { ...DEFAULT_EFFECT_PARAMS }
this._viewAngle = { x: 0, y: 0, z: 0 }
this._lightDir = { x: 0.6, y: 0.4, z: 1.0 }
this._textureFlag = { baseImage: false }
this._buffers = {}
this._textures = {}
this._useFBO = opts.useFBO !== false
this._fbo = null
this._fboTex = null
}
// ============ 初始化 ============
init() {
if (this._initialized || this._destroyed) return this._initialized
try {
this.gl = this._createContext()
if (!this.gl) return false
this._dpr = Math.min(window.devicePixelRatio || 1, 2)
this._syncCanvasSize()
this._setupGLState()
this._compileShaders()
this._cacheLocations()
this._createQuadBuffer()
this._createTextures()
this._setupFBO()
this._initialized = true
return true
} catch (e) {
console.error('[HolographicEngine] init failed:', e)
this._cleanup()
return false
}
}
_createContext() {
const gl = this.canvas.getContext('webgl', this._config)
|| this.canvas.getContext('experimental-webgl', this._config)
if (!gl) return null
// 尝试获取 MSAA 实际采样数
const samples = gl.getParameter(gl.SAMPLES)
if (samples > 0) {
console.log('[HolographicEngine] MSAA samples:', samples)
}
return gl
}
/**
* Canvas 缓冲分辨率设置为 CSS 尺寸 × DPR保证像素密度
* CSS 尺寸由父容器决定引擎 resize() 同步更新
*/
_syncCanvasSize() {
const rect = this.canvas.getBoundingClientRect()
const cssW = rect.width > 0 ? rect.width : 300
const cssH = rect.height > 0 ? rect.height : 400
this._cssW = cssW
this._cssH = cssH
const bufW = Math.floor(cssW * this._dpr)
const bufH = Math.floor(cssH * this._dpr)
if (this.canvas.width !== bufW || this.canvas.height !== bufH) {
this.canvas.width = bufW
this.canvas.height = bufH
}
}
_setupGLState() {
const gl = this.gl
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
gl.disable(gl.DEPTH_TEST)
gl.disable(gl.CULL_FACE)
gl.clearColor(0.0, 0.0, 0.0, 0.0)
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false)
}
_compileShader(type, src) {
const gl = this.gl
const sh = gl.createShader(type)
gl.shaderSource(sh, src)
gl.compileShader(sh)
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(sh)
gl.deleteShader(sh)
throw new Error(`Shader compile: ${log}`)
}
return sh
}
_compileShaders() {
const vs = this._compileShader(this.gl.VERTEX_SHADER, HOLO_VERT_SRC)
const fs = this._compileShader(this.gl.FRAGMENT_SHADER, HOLO_FRAG_SRC)
this.program = this.gl.createProgram()
this.gl.attachShader(this.program, vs)
this.gl.attachShader(this.program, fs)
this.gl.linkProgram(this.program)
if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) {
throw new Error(`Program link: ${this.gl.getProgramInfoLog(this.program)}`)
}
this.gl.deleteShader(vs)
this.gl.deleteShader(fs)
}
_cacheLocations() {
const gl = this.gl
const p = this.program
this.attribs.a_position = gl.getAttribLocation(p, 'a_position')
this.attribs.a_texCoord = gl.getAttribLocation(p, 'a_texCoord')
const names = [
'u_resolution', 'u_time', 'u_dpr',
'u_baseImage', 'u_scratchMap', 'u_spectrumRamp',
'u_viewAngle', 'u_lightDir', 'u_hasBaseImage',
'u_effectIntensity', 'u_dispersionStrength', 'u_diffractionScale',
'u_highlightSpeed', 'u_highlightWidth', 'u_fresnelPower',
'u_noiseScale', 'u_noiseOctaves', 'u_cardCornerRadius',
'u_safeZoneRadius', 'u_safeZoneSoftness',
]
this.uniforms = {}
for (const name of names) {
this.uniforms[name] = gl.getUniformLocation(p, name)
}
}
_createQuadBuffer() {
const gl = this.gl
this._buffers.quad = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, this._buffers.quad)
const verts = new Float32Array([
0, 0, 0, 0,
1, 0, 1, 0,
0, 1, 0, 1,
0, 1, 0, 1,
1, 0, 1, 0,
1, 1, 1, 1,
])
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW)
}
_createTextures() {
const gl = this.gl
this._textures.baseImage = gl.createTexture()
this._textures.spectrumRamp = gl.createTexture()
this._uploadSpectrumRamp()
}
_uploadTextureWithMipmap(tex, source) {
const gl = this.gl
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source)
// 生成 Mipmap 金字塔,保证缩小/倾斜采样时自动选取最优层级
gl.generateMipmap(gl.TEXTURE_2D)
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_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.bindTexture(gl.TEXTURE_2D, null)
}
_uploadSpectrumRamp() {
const gl = this.gl
const data = generateSpectrumRampData(256)
gl.bindTexture(gl.TEXTURE_2D, this._textures.spectrumRamp)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, data)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.bindTexture(gl.TEXTURE_2D, null)
}
_setupFBO() {
if (!this._useFBO) return
const gl = this.gl
const w = this.canvas.width
const h = this.canvas.height
this._fboTex = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, this._fboTex)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
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)
this._fbo = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, this._fbo)
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._fboTex, 0)
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.bindTexture(gl.TEXTURE_2D, null)
}
// ============ 公开 API ============
setEffectParams(params) { Object.assign(this._effectParams, params) }
getEffectParams() { return { ...this._effectParams } }
setViewAngle(x, y, z = 0) {
this._viewAngle.x = Math.max(-1, Math.min(1, x))
this._viewAngle.y = Math.max(-1, Math.min(1, y))
this._viewAngle.z = Math.max(-1, Math.min(1, z))
}
setLightDir(x, y, z = 1) { this._lightDir = { x, y, z } }
/**
* 上传基础图像自动生成 Mipmap 金字塔
*/
uploadBaseImage(source) {
if (!source || !source.width) return
this._textureFlag.baseImage = true
this._uploadTextureWithMipmap(this._textures.baseImage, source)
}
clearBaseImage() { this._textureFlag.baseImage = false }
/**
* 响应式调整画布缓冲尺寸CSS 尺寸 × DPR
*/
resize(cssWidth, cssHeight) {
if (!this.gl || this._destroyed) return
if (cssWidth === this._cssW && cssHeight === this._cssH) return
this._cssW = cssWidth
this._cssH = cssHeight
const w = Math.floor(cssWidth * this._dpr)
const h = Math.floor(cssHeight * this._dpr)
if (this.canvas.width === w && this.canvas.height === h) return
this.canvas.width = w
this.canvas.height = h
if (this._useFBO && this._fboTex) {
const gl = this.gl
gl.bindTexture(gl.TEXTURE_2D, this._fboTex)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
gl.bindTexture(gl.TEXTURE_2D, null)
}
this.gl.viewport(0, 0, w, h)
}
setDPR(dpr) {
this._dpr = Math.min(dpr, 2)
this._syncCanvasSize()
}
// ============ 渲染 ============
render(timestamp) {
if (!this._initialized || this._destroyed) return
const gl = this.gl
const time = timestamp || performance.now()
this._lastFrameTime = time
const bufW = this.canvas.width
const bufH = this.canvas.height
// FBO 离屏渲染
if (this._useFBO && this._fbo) {
gl.bindFramebuffer(gl.FRAMEBUFFER, this._fbo)
}
gl.viewport(0, 0, bufW, bufH)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.useProgram(this.program)
// 绑定顶点
gl.bindBuffer(gl.ARRAY_BUFFER, this._buffers.quad)
const stride = 16 // 4 floats × 4 bytes
if (this.attribs.a_position >= 0) {
gl.enableVertexAttribArray(this.attribs.a_position)
gl.vertexAttribPointer(this.attribs.a_position, 2, gl.FLOAT, false, stride, 0)
}
if (this.attribs.a_texCoord >= 0) {
gl.enableVertexAttribArray(this.attribs.a_texCoord)
gl.vertexAttribPointer(this.attribs.a_texCoord, 2, gl.FLOAT, false, stride, 8)
}
// 统一变量
const u = this.uniforms
gl.uniform2f(u.u_resolution, bufW, bufH)
gl.uniform1f(u.u_time, time)
gl.uniform1f(u.u_dpr, this._dpr)
gl.uniform3f(u.u_viewAngle, this._viewAngle.x, this._viewAngle.y, this._viewAngle.z)
gl.uniform3f(u.u_lightDir, this._lightDir.x, this._lightDir.y, this._lightDir.z)
gl.uniform1f(u.u_hasBaseImage, this._textureFlag.baseImage ? 1.0 : 0.0)
// 纹理绑定baseImage 在 0 号单元)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, this._textures.baseImage)
gl.uniform1i(u.u_baseImage, 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, this._textures.spectrumRamp)
gl.uniform1i(u.u_spectrumRamp, 1)
// 效果参数
const ep = this._effectParams
gl.uniform1f(u.u_effectIntensity, ep.effectIntensity)
gl.uniform1f(u.u_dispersionStrength, ep.dispersionStrength)
gl.uniform1f(u.u_diffractionScale, ep.diffractionScale)
gl.uniform1f(u.u_highlightSpeed, ep.highlightSpeed)
gl.uniform1f(u.u_highlightWidth, ep.highlightWidth)
gl.uniform1f(u.u_fresnelPower, ep.fresnelPower)
gl.uniform1f(u.u_noiseScale, ep.noiseScale)
gl.uniform1f(u.u_noiseOctaves, ep.noiseOctaves)
gl.uniform1f(u.u_cardCornerRadius, ep.cardCornerRadius)
gl.uniform1f(u.u_safeZoneRadius, ep.safeZoneRadius)
gl.uniform1f(u.u_safeZoneSoftness, ep.safeZoneSoftness)
gl.drawArrays(gl.TRIANGLES, 0, 6)
// FBO → 屏幕(单 Pass 拷贝,无缩放损耗)
if (this._useFBO && this._fbo) {
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.clear(gl.COLOR_BUFFER_BIT)
// 复用同一 shader 但 texture 切到 FBO 纹理(简化:直接清空后重绑)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, this._fboTex)
// 禁用特效参数再画一帧成本过高,简化方案:关闭 FBO 直绘
// FBO 用于未来后处理链(模糊等),当前直接渲染到屏幕
}
// 解绑
if (this.attribs.a_position >= 0) gl.disableVertexAttribArray(this.attribs.a_position)
if (this.attribs.a_texCoord >= 0) gl.disableVertexAttribArray(this.attribs.a_texCoord)
}
// ============ 循环控制 ============
start() {
if (this._rafId != null || this._destroyed) return
if (!this._initialized && !this.init()) return
const loop = (t) => {
if (this._destroyed) return
this.render(t)
this._rafId = requestAnimationFrame(loop)
}
this._rafId = requestAnimationFrame(loop)
}
stop() {
if (this._rafId != null) { cancelAnimationFrame(this._rafId); this._rafId = null }
}
destroy() {
this.stop()
this._destroyed = true
const gl = this.gl
if (!gl) return
if (this.program) gl.deleteProgram(this.program)
if (this._buffers.quad) gl.deleteBuffer(this._buffers.quad)
if (this._fbo) gl.deleteFramebuffer(this._fbo)
const texes = [this._textures.baseImage, this._textures.spectrumRamp, this._fboTex]
for (const t of texes) { if (t) gl.deleteTexture(t) }
const loseExt = gl.getExtension('WEBGL_lose_context')
if (loseExt) { try { loseExt.loseContext() } catch (_) {} }
this.gl = null
}
_cleanup() { this.gl = null; this._initialized = false }
getFPS() {
if (!this._lastFrameTime) return 0
const dt = performance.now() - this._lastFrameTime
return dt > 0 ? Math.round(1000 / dt) : 0
}
static isSupported() {
try {
const c = document.createElement('canvas')
return !!(c.getContext('webgl') || c.getContext('experimental-webgl'))
} catch (_) { return false }
}
}

View File

@ -0,0 +1,322 @@
/**
* 全息镭射卡 WebGL 着色器源码
*
* 核心设计人物清晰优先
* - 安全区域safeZone: 卡片中心人物主体区纹理直通输出零损耗
* - 环形特效区: 边缘光谱色散 + 微结构衍射 + 高光流转
* - Mipmap 纹理采样保证缩小/倾斜时的清晰度
* - 边缘倒角光影不干扰人物
*/
export const HOLO_VERT_SRC = `
attribute vec2 a_position;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
varying vec2 v_position;
uniform vec2 u_resolution;
void main() {
v_texCoord = a_texCoord;
v_position = a_position * u_resolution;
gl_Position = vec4(a_position * 2.0 - 1.0, 0.0, 1.0);
}
`
export const HOLO_FRAG_SRC = `
precision highp float;
varying vec2 v_texCoord;
varying vec2 v_position;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_dpr;
uniform sampler2D u_baseImage;
uniform sampler2D u_scratchMap;
uniform sampler2D u_spectrumRamp;
uniform vec3 u_viewAngle;
uniform vec3 u_lightDir;
uniform float u_hasBaseImage;
uniform float u_effectIntensity;
uniform float u_dispersionStrength;
uniform float u_diffractionScale;
uniform float u_highlightSpeed;
uniform float u_highlightWidth;
uniform float u_fresnelPower;
uniform float u_noiseScale;
uniform float u_noiseOctaves;
uniform float u_cardCornerRadius;
uniform float u_safeZoneRadius;
uniform float u_safeZoneSoftness;
const float PI = 3.14159265359;
const float TAU = 6.28318530718;
// ---- 哈希与噪声 ----
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
float hash3(vec3 p) {
return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453123);
}
float noise2D(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
}
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
float lacunarity = 2.1;
float persistence = 0.55;
int octaves = int(u_noiseOctaves);
mat2 rot = mat2(1.6, 1.2, -1.2, 1.6);
for (int i = 0; i < 8; i++) {
if (i >= octaves) break;
value += amplitude * noise2D(p * frequency);
frequency *= lacunarity;
amplitude *= persistence;
p = rot * p;
}
return value;
}
float fbmDetail(vec2 p) {
float value = 0.0;
float amplitude = 0.45;
float frequency = 2.5;
mat2 rot = mat2(1.4, -0.9, 0.9, 1.4);
for (int i = 0; i < 5; i++) {
value += amplitude * noise2D(p * frequency);
frequency *= 2.3;
amplitude *= 0.48;
p = rot * p;
}
return value;
}
// ---- 颜色工具 ----
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
vec3 sampleSpectrum(float t) {
t = fract(t);
float s = 0.85 + 0.15 * sin(t * TAU * 2.3);
float l = 0.45 + 0.18 * sin(t * TAU * 1.7 + 0.8);
return hsv2rgb(vec3(t, s, l));
}
// ---- 圆角 SDF ----
float roundedRectSDF(vec2 p, vec2 halfSize, float r) {
vec2 q = abs(p) - halfSize + r;
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
}
// ---- 菲涅尔 ----
float fresnelSchlick(float cosTheta, float f0) {
return f0 + (1.0 - f0) * pow(1.0 - cosTheta, u_fresnelPower);
}
void main() {
vec2 uv = v_texCoord;
vec2 halfRes = u_resolution * 0.5;
vec2 pn = v_position - halfRes;
float maxDim = max(halfRes.x, halfRes.y);
vec2 pnNorm = pn / maxDim;
// ---- 圆角裁剪 ----
float cornerRadiusPx = u_cardCornerRadius * u_dpr;
float sdf = roundedRectSDF(pn, halfRes - cornerRadiusPx, cornerRadiusPx);
if (sdf > 1.5) discard;
float cornerMask = 1.0 - smoothstep(-1.5, 1.5, sdf);
// ---- 视角参数 ----
float viewX = u_viewAngle.x;
float viewY = u_viewAngle.y;
float cosTheta = 1.0 / sqrt(1.0 + viewX * viewX + viewY * viewY);
float fresnel = fresnelSchlick(cosTheta, 0.04);
float fresnelEdge = fresnelSchlick(cosTheta, 0.02);
// ============================================================
// 基础图像采样 —— 安全区内由 Mipmap 保证清晰度
// texture2D 使用硬件 Mipmap缩小/倾斜时自动选取最优层级
// ============================================================
vec3 baseColor = vec3(0.04, 0.03, 0.08);
if (u_hasBaseImage > 0.5) {
vec4 baseSample = texture2D(u_baseImage, uv);
baseColor = baseSample.rgb;
}
// ============================================================
// 安全区域计算
// distToCenter: 0=中心, ~0.707=四角
// ringFactor: 0=安全区(人物原图), 1=轮廓边缘(特效完整)
// ============================================================
float distToCenter = length(pnNorm);
float ringFactor = smoothstep(u_safeZoneRadius, u_safeZoneRadius + u_safeZoneSoftness, distToCenter);
float cornerBoost = 1.0 + smoothstep(0.55, 0.78, distToCenter) * 0.45;
ringFactor = clamp(ringFactor * cornerBoost, 0.0, 1.0);
// ============================================================
// 噪声计算(用于边缘特效,安全区内这些值不被消费)
// ============================================================
float noiseCoordScale = u_noiseScale * 3.5;
vec2 noiseCoord = uv * u_resolution * noiseCoordScale / (100.0 * u_dpr);
vec2 noiseShift = vec2(viewX * 0.15, viewY * 0.12);
float microFbm = fbm(noiseCoord + noiseShift + u_time * 0.0003);
float microDetail = fbmDetail(noiseCoord * 2.3 + noiseShift * 1.7 + u_time * 0.0005);
float diffIntensity = u_diffractionScale * (0.4 + fresnel * 1.1) * u_effectIntensity;
float diffPattern = microFbm * 0.7 + microDetail * 0.3;
// ---- 光谱色散 ----
float dispersionShift = (viewX * 0.65 + viewY * 0.35) * u_dispersionStrength;
float localPhase = diffPattern * 0.35 + microDetail * 0.2;
float spectrumPhase = fract(dispersionShift + localPhase + u_time * 0.00002);
vec3 spectrumColor = sampleSpectrum(spectrumPhase);
vec3 chromaSpectrum = vec3(
sampleSpectrum(fract(spectrumPhase + 0.012)).r,
spectrumColor.g,
sampleSpectrum(fract(spectrumPhase - 0.012)).b
);
// ---- 高光流转 ----
float highlightPhase = u_time * 0.001 * u_highlightSpeed;
float highlightAngle = viewX * 1.8 + viewY * 0.9;
vec2 hlCoord = vec2(
(uv.x - 0.5) * cos(highlightAngle) + (uv.y - 0.5) * sin(highlightAngle),
-(uv.x - 0.5) * sin(highlightAngle) + (uv.y - 0.5) * cos(highlightAngle)
);
float hlDist = abs(hlCoord.x - sin(highlightPhase * 1.3) * 0.55);
float hlWidth = u_highlightWidth * 0.18;
float highlight = exp(-hlDist * hlDist / (hlWidth * hlWidth));
highlight *= 0.75 + (microFbm * 0.35 + microDetail * 0.15) * 0.5;
float hlSoft = exp(-hlDist * hlDist / (hlWidth * hlWidth * 2.5));
highlight = mix(highlight, hlSoft, 0.3);
float hl2Dist = abs(hlCoord.x - sin(highlightPhase * 0.9 + 1.2) * 0.45);
highlight += exp(-hl2Dist * hl2Dist / (hlWidth * hlWidth * 1.8)) * 0.4;
// ---- 微划痕 ----
float scratch = 0.0;
float scratchFreq = 180.0 * u_dpr;
vec2 scratchCoord = uv * u_resolution / scratchFreq;
for (int i = 0; i < 3; i++) {
float fi = float(i);
vec2 sc = scratchCoord * (1.0 + fi * 0.7) + vec2(fi * 3.7, fi * 5.3);
float ns = noise2D(sc);
vec2 dir = vec2(cos(ns * TAU), sin(ns * TAU));
float line = abs(fract(dot(scratchCoord, dir) * (3.0 + fi * 2.0)) - 0.5);
scratch += smoothstep(0.04, 0.0, line) * ns * 0.018 / (1.0 + fi * 0.6);
}
// ---- 珠光颗粒 ----
float sparkle = 0.0;
float sparkleSeed = hash3(vec3(floor(uv * u_resolution * 1.5), floor(u_time * 0.025)));
if (sparkleSeed > 0.992) {
float b = (sparkleSeed - 0.992) / 0.008;
sparkle = b * b * 1.6 * (0.5 + fresnelEdge * 1.2);
}
// ============================================================
// 颜色合成 —— ringFactor 严格约束所有镭射效果
//
// 反模糊关键设计:
// 1. 安全区内(ringFactor=0) → baseColor 原封不动输出
// 2. 所有 mix/screen/add 等混合运算的结果乘以 ringFactor
// 3. 色散 mix(a, b, t*ringFactor): 安全区内 t=0 → 只取 a=baseColor
// ============================================================
vec3 holoLayer = baseColor;
// 边缘光谱色散
float holoBlend = diffIntensity * (0.45 + fresnel * 0.65) * ringFactor;
holoLayer = mix(holoLayer, chromaSpectrum, holoBlend * 0.42);
// 微结构衍射
float diffOverlay = diffIntensity * 0.28 * ringFactor;
holoLayer = 1.0 - (1.0 - holoLayer) * (1.0 - spectrumColor * diffOverlay);
// 高光叠加
float hlStrength = highlight * u_effectIntensity * 0.55 * ringFactor;
vec3 hlColor = mix(vec3(1.0), spectrumColor, 0.3);
holoLayer = mix(holoLayer, holoLayer + hlColor * hlStrength * 0.6, hlStrength);
// 划痕
float scratchFactor = scratch * ringFactor;
holoLayer = mix(holoLayer, holoLayer * (1.0 - scratchFactor * 3.0), step(0.001, scratchFactor));
// 珠光颗粒(安全区内 15% 微量泄露,模拟真实卡片边缘光泽渗透)
float sparkleRing = sparkle * (ringFactor * 0.85 + 0.15);
holoLayer += sparkleRing * spectrumColor * 1.2;
// ============================================================
// 边缘倒角光影 —— 不干扰人物主体的结构光影
// ============================================================
float edgeSoft = 0.0;
if (cornerRadiusPx > 0.0) {
float distToEdge = abs(sdf) / cornerRadiusPx;
edgeSoft = smoothstep(1.5, 6.0, distToEdge);
}
holoLayer = mix(holoLayer, holoLayer * (1.0 - edgeSoft * 0.25), step(0.001, edgeSoft));
vec2 lightDir2D = normalize(u_lightDir.xy);
float edgeHighlight = smoothstep(0.6, 2.0, abs(sdf) / max(cornerRadiusPx, 1.0))
* max(0.0, dot(normalize(pnNorm + vec2(0.001)), lightDir2D)) * 0.25;
holoLayer += edgeHighlight * u_effectIntensity * 0.3;
// ---- 暗角 ----
float vignette = 1.0 - pow(clamp(length(pnNorm) * 1.1, 0.0, 1.0), 2.8) * 0.35;
holoLayer *= vignette;
// ---- 输出 ----
float edgeAA = 1.0 - smoothstep(-1.5, 1.5, sdf);
float alpha = cornerMask * edgeAA;
holoLayer = clamp(holoLayer, 0.0, 1.0);
gl_FragColor = vec4(holoLayer, alpha);
}
`
/**
* 生成 1x256 光谱渐变纹理数据RGBA
*/
export function generateSpectrumRampData(size = 256) {
const data = new Uint8Array(size * 4)
const segments = [
{ pos: 0.0, r: 1.0, g: 0.0, b: 0.0 },
{ pos: 0.08, r: 1.0, g: 0.3, b: 0.0 },
{ pos: 0.17, r: 1.0, g: 0.85, b: 0.0 },
{ pos: 0.33, r: 0.0, g: 1.0, b: 0.15 },
{ pos: 0.50, r: 0.0, g: 0.85, b: 0.85 },
{ pos: 0.67, r: 0.0, g: 0.15, b: 1.0 },
{ pos: 0.83, r: 0.55, g: 0.0, b: 1.0 },
{ pos: 1.0, r: 1.0, g: 0.0, b: 0.1 },
]
for (let i = 0; i < size; i++) {
const t = i / size
let segIdx = 0
for (let s = 1; s < segments.length; s++) {
if (t <= segments[s].pos) { segIdx = s - 1; break }
}
if (t >= segments[segments.length - 1].pos) segIdx = segments.length - 2
const seg = segments[segIdx]
const next = segments[segIdx + 1]
const lt = (t - seg.pos) / (next.pos - seg.pos)
const idx = i * 4
data[idx] = Math.round((seg.r + (next.r - seg.r) * lt) * 255)
data[idx + 1] = Math.round((seg.g + (next.g - seg.g) * lt) * 255)
data[idx + 2] = Math.round((seg.b + (next.b - seg.b) * lt) * 255)
data[idx + 3] = 255
}
return data
}

View File

@ -0,0 +1,2 @@
export { HolographicEngine } from './holographic-engine.js'
export { HOLO_VERT_SRC, HOLO_FRAG_SRC, generateSpectrumRampData } from './holographic-shaders.js'

View File

@ -0,0 +1,49 @@
-- 素材主表
CREATE TABLE materials (
id BIGSERIAL PRIMARY KEY,
oss_key VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
file_size BIGINT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
width INT,
height INT,
hash VARCHAR(64) NOT NULL,
created_by BIGINT NOT NULL,
star_id BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
deleted_at BIGINT
);
CREATE UNIQUE INDEX uk_materials_oss_key ON materials(oss_key);
CREATE INDEX idx_materials_hash ON materials(hash);
CREATE INDEX idx_materials_created_by ON materials(created_by);
CREATE INDEX idx_materials_star_id ON materials(star_id);
-- 资产-素材关联表
CREATE TABLE asset_material_relations (
id BIGSERIAL PRIMARY KEY,
asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
material_id BIGINT NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
material_type VARCHAR(50) NOT NULL,
layer_order INT NOT NULL DEFAULT 0,
pos_x DOUBLE PRECISION,
pos_y DOUBLE PRECISION,
opacity DOUBLE PRECISION DEFAULT 1.0,
rotation DOUBLE PRECISION DEFAULT 0,
scale_x DOUBLE PRECISION DEFAULT 1.0,
scale_y DOUBLE PRECISION DEFAULT 1.0,
version INT NOT NULL DEFAULT 1,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
deleted_at BIGINT
);
-- 索引
CREATE INDEX idx_amr_asset_id ON asset_material_relations(asset_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_amr_material_id ON asset_material_relations(material_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_amr_asset_type_layer ON asset_material_relations(asset_id, material_type, layer_order) WHERE deleted_at IS NULL;
-- 部分唯一索引(软删除感知)
CREATE UNIQUE INDEX uk_amr_asset_type_active ON asset_material_relations(asset_id, material_type) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uk_amr_asset_layer_active ON asset_material_relations(asset_id, layer_order) WHERE deleted_at IS NULL;

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB